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/CHANGELOG.md CHANGED
@@ -2,6 +2,91 @@
2
2
 
3
3
  Release history for the DaVinci Resolve MCP Server. The latest release is summarized in the root README; older entries live here to keep the README focused.
4
4
 
5
+ ## What's New in v2.70.0
6
+
7
+ Headless (`-nogui`) Resolve, measured rather than assumed — and the finding
8
+ reverses this project's working belief about it.
9
+
10
+ ### The measurement
11
+
12
+ A GUI run and a `-nogui` run of the same Resolve Studio 19.1.3.7 were probed
13
+ identically and differenced: 139 read-only API probes plus 13 write scenarios
14
+ covering pages, media pool, timeline editing, colour, Fusion comps, Gallery,
15
+ render-to-disk, interchange export, layout presets, playhead and project
16
+ settings. 238 paired observations.
17
+
18
+ **Zero capabilities worked with a UI and failed without one.** Render to disk,
19
+ AAF/EDL/FCPXML/DRT/OTIO export, `ExportCurrentFrameAsStill`, `GrabStill`, Fusion
20
+ comp create/delete, colour groups, and even UI layout presets all behave
21
+ identically headless.
22
+
23
+ **Scope, stated up front:** that is a result about *capability* and about
24
+ modals. It is **not** a stability result. The probe's render was 2 seconds of
25
+ 640×360 H.264; long renders, JPEG 2000 decode out of DCP MXF, ProRes-into-MXF
26
+ writes and repeated jobs in one session are all untested, and there is a field
27
+ report of headless crashing *on completion of writes* for exactly that class of
28
+ job. The docs and the runtime guidance say so rather than letting the matrix
29
+ be read as a blanket endorsement.
30
+
31
+ The one difference actually found runs the other way, and it is the one an agent
32
+ cares about: **a GUI Resolve can raise a modal dialog no script can dismiss;
33
+ headless cannot.** The trigger is project switching, and the obvious defence fails —
34
+ `ProjectManager.SaveProject()` returns `False` for the default never-saved
35
+ `Untitled Project`, which is exactly the project that raises the prompt.
36
+ Headless returns the same `False` and switches anyway.
37
+
38
+ Two earlier notes are corrected: `ExportCurrentFrameAsStill` was recorded as a
39
+ headless-only failure and works headless; `ExportStills` was recorded the same
40
+ way and fails in *both* modes (its documented cause is Gallery panel visibility,
41
+ which no headless session and no panel-closed GUI session can satisfy).
42
+
43
+ ### Fixed
44
+
45
+ - **`timeline(action="get_items_in_track")` returned object reprs, not items.**
46
+ The handler passed `GetItemListInTrack`'s result to `_ser`, which has no
47
+ TimelineItem branch and falls through to `str(obj)`, so callers got
48
+ `["<PyRemoteObject at 0x…>", …]` instead of clip data. The docstring's claim of
49
+ "full serialization of each item" was never true. The sibling `get_items`
50
+ takes the same params through the same `_track_selector` and calls the same API
51
+ method, so the two are now one handler and `get_items_in_track` is documented
52
+ as its alias — there was no richer per-item serializer to preserve. Thanks
53
+ [@billcarroll](https://github.com/billcarroll) (#105).
54
+
55
+ ### Added
56
+
57
+ - `resolve_control(action="runtime_mode")` — is Resolve up, and does it have a
58
+ UI? Answers with no connection needed, and carries the mode-specific guidance
59
+ with it. **There is no API tell**: a headless instance returns a real page from
60
+ `GetCurrentPage()` and identical product/version strings, so this reads the
61
+ process argv, which is the only place `-nogui` appears. `headless` is `null`
62
+ when undeterminable — never read that as `false`.
63
+ - `resolve_control(action="launch", params={"headless": true})`, plus
64
+ `DAVINCI_RESOLVE_HEADLESS=1` to make auto-launch headless. Launching the other
65
+ mode while an instance is running returns `RESOLVE_MODE_CONFLICT` instead of
66
+ starting a second one that would fight the singleton.
67
+ - `src/utils/resolve_runtime.py` — mode detection and launch-command
68
+ construction. Headless launch runs the binary inside the app bundle, because
69
+ `open -a` hands the argument list to LaunchServices, which discards `-nogui`
70
+ and gives you a window with no error.
71
+ - `scripts/resolve_headless.py` — batch/CI entry point: `status`, `guard`,
72
+ `start`, `stop`, and `run -- <cmd>` which only stops what it started.
73
+ - `scripts/headless_differential.py` + `src/utils/headless_differential.py` —
74
+ the harness that produced the above, rerunnable after a Resolve upgrade.
75
+ - `docs/reference/headless-cli.md` — verified flags, environment variables,
76
+ launch/teardown recipes, singleton rules, and the flags found in the
77
+ application binary but deliberately *not* exercised.
78
+ - `docs/reference/headless-capability-matrix.md` — the generated differential.
79
+ - Three `api_truth` entries: the `SaveProject`/modal trap, the absence of any
80
+ headless API tell, and the corrected `ExportStills` reality.
81
+
82
+ ### Validation
83
+
84
+ - Offline suite 2280 → 2305 (`tests/test_headless_runtime.py`, 25 tests).
85
+ - Live: full differential recorded in both modes on Resolve Studio 19.1.3.7,
86
+ scratch project created and deleted per run, GUI session restored afterwards.
87
+ Measured teardown 2.1s via `Quit()`, headless boot to scriptable under 3s,
88
+ scripting listener on TCP 15000 in both modes.
89
+
5
90
  ## What's New in v2.69.3
6
91
 
7
92
  Ships the v2.69.2 bundle, which never reached npm. Same fixes, plus the one that
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # DaVinci Resolve MCP Server
2
2
 
3
- [![Version](https://img.shields.io/badge/version-2.69.3-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
3
+ [![Version](https://img.shields.io/badge/version-2.70.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
4
4
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
5
5
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
6
6
  [![Tools](https://img.shields.io/badge/MCP%20Tools-34%20(341%20full)-blue.svg)](#server-modes)
package/docs/SKILL.md CHANGED
@@ -218,6 +218,81 @@ respect this hint pass it as `tool_choice={type:"tool", name:"media_analysis"}`
218
218
  on the next API turn, hard-locking the agent into the correct next call. Hosts
219
219
  that don't recognize the field ignore it — the flow is unchanged for them.
220
220
 
221
+ ## Headless Resolve (`-nogui`)
222
+
223
+ Resolve runs without a UI, and it is **capability-identical** to a GUI session.
224
+ Measured across 238 paired observations on Studio 19.1.3.7 (see
225
+ `docs/reference/headless-cli.md` and the regenerable
226
+ `docs/reference/headless-capability-matrix.md`): **zero** capabilities work with
227
+ a UI and fail without one. Pages, render-to-disk, AAF/EDL/XML/DRT/OTIO export,
228
+ `ExportCurrentFrameAsStill`, Fusion comps, colour groups, layout presets and all
229
+ ordinary editorial behave identically.
230
+
231
+ **Capability is not stability, but stability is now partly measured too.** Ten
232
+ consecutive ProRes 422 HQ renders in one headless session, from a JPEG 2000 /
233
+ MXF OP1A source, completed with no crash, no death and no leak — marginally
234
+ faster and ~95 MB lighter than the same ten renders with a UI. What remains
235
+ untested is *sustained* encode over hours and the operator's own footage and
236
+ codec settings. Use headless freely for orchestration, edit, conform, analysis
237
+ and renders of that scale; qualify it on real footage before promising a
238
+ long-form or professional-container delivery, and keep a GUI fallback there.
239
+
240
+ **Headless is NOT immune to modal dialogs — it is worse.** It cannot *display* a
241
+ dialog, but Resolve still tries to raise one, and the call then never returns.
242
+
243
+ The canonical case, measured on a cold `-nogui` boot:
244
+ `ProjectManager.SaveProject()` on the default never-saved project named
245
+ `Untitled Project` **blocks forever** headless (no return after 45s, client
246
+ parked in `Fusion::RemoteApp::WaitPkt`), where the GUI merely returns `False`.
247
+ The project has no location and there is no `SaveProjectAs`, so Resolve wants a
248
+ Save-As dialog and waits for an answer that can never arrive. In the GUI a human
249
+ clears it in one click; headless nothing can.
250
+
251
+ **Never call `SaveProject()` without checking the project name first:**
252
+
253
+ ```python
254
+ project = pm.GetCurrentProject()
255
+ if project is not None and project.GetName() != "Untitled Project":
256
+ pm.SaveProject() # safe: it has a location
257
+ # else: nothing to save, and headless this call blocks forever
258
+ ```
259
+
260
+ `src/utils/project_cleanup.py:save_project_if_safe(pm)` does exactly this — use
261
+ it rather than calling `SaveProject` directly. And do **not** reach for headless
262
+ to dodge the GUI's save dialog; that trade makes a one-click interruption into a
263
+ dead session.
264
+
265
+ Rules:
266
+
267
+ - **Check the mode before any project switch.** `resolve_control(action="runtime_mode")`
268
+ → `{running, headless, instances, database_attached, guidance}`. It needs no
269
+ connection.
270
+ - **`database_attached: false` means the instance is WEDGED — stop and restart it.**
271
+ Resolve can come up with no project database attached. It accepts connections
272
+ and answers product, version, page and current-project queries normally, so
273
+ every ordinary liveness check passes, while `CreateProject`/`LoadProject`
274
+ return False forever, `SaveProject` returns None, and some calls never return
275
+ at all. It does not recover on its own. Do not retry; quit and relaunch.
276
+ - **`headless` may be `null`.** That means "cannot be determined", not "has a UI".
277
+ Treat `null` like `false` — take the careful path — but do not report it as fact.
278
+ - **There is no API tell.** A headless instance returns a real page from
279
+ `GetCurrentPage()` and identical product/version strings. Anything that
280
+ inspects the `resolve` handle to guess the mode is guessing. `runtime_mode`
281
+ reads the process argv, which is the only place `-nogui` appears.
282
+ - **Launching:** `resolve_control(action="launch", params={"headless": true})`,
283
+ or set `DAVINCI_RESOLVE_HEADLESS=1` to make auto-launch headless. Launching
284
+ the other mode while an instance is already running returns
285
+ `RESOLVE_MODE_CONFLICT` rather than starting a second one — two Resolves fight
286
+ the singleton and have been observed to crash-loop rather than fail cleanly.
287
+ - **`instances` > 1 is a fault to report**, not a state to work around. Check for
288
+ a render node before starting anything.
289
+ - Teardown is `resolve_control(action="quit")`; it discards the open project
290
+ without prompting, which is what a batch process wants.
291
+
292
+ The one thing headless genuinely cannot do is anything that needs a *visible
293
+ panel* — `ExportStills` being the known case, and it fails in a panel-closed GUI
294
+ too.
295
+
221
296
  ## Local Control Panel
222
297
 
223
298
  If the user asks to open, launch, or inspect the Resolve MCP control panel, run
@@ -1850,7 +1925,9 @@ timeline item returns `False` in Resolve. Use `get_node_graph` without a
1850
1925
 
1851
1926
  **Gallery export requires the Gallery panel visible** — `ExportStills` only works
1852
1927
  if the Gallery panel is open in the Resolve UI on the Color page. Instruct the
1853
- user to open it via Workspace menu if export fails.
1928
+ user to open it via Workspace menu if export fails. Measured to fail in *both*
1929
+ GUI (panel closed) and headless sessions, so a failure is not a reason to switch
1930
+ modes. For pixels use `Project.ExportCurrentFrameAsStill`, which works in both.
1854
1931
 
1855
1932
  **Python version** — the only hard requirement is Python **3.10+** (the MCP SDK
1856
1933
  floor). There is no upper cap: 3.13/3.14 are accepted, and Python 3.14 is verified
@@ -0,0 +1,238 @@
1
+ # Building edits headless: the iterative loop
2
+
3
+ How to drive Resolve entirely from the command line to import media, link it,
4
+ assemble a cut, and then round-trip that cut repeatedly — with the format and
5
+ option choices justified by measurement rather than habit.
6
+
7
+ Everything here was measured on Resolve Studio 19.1.3.7 in **both** GUI and
8
+ `-nogui` sessions on 2026-08-01. Regenerate with `scripts/roundtrip_matrix.py`;
9
+ the raw runs are in `docs/reference/evidence/`.
10
+
11
+ ## The short answer
12
+
13
+ **Mode does not matter.** Every result below is byte-identical between GUI and
14
+ `-nogui` — the flat round trip, the complex-cut round trip, and the moved-media
15
+ relink. Choose a format on its properties, never on whether you have a UI.
16
+
17
+ **There is no single best format.** Three measurements pull in different
18
+ directions, and the right choice depends on which one you are up against:
19
+
20
+ | what you need | format | why |
21
+ | --- | --- | --- |
22
+ | **Full fidelity**, media staying put | **DRT** | the only format that kept *everything*: transforms, colours, flags, item and timeline markers |
23
+ | **Iterative loop**, media staying put | **FCP7 XML** or AAF | no media duplication, controllable timeline name — DRT has neither |
24
+ | **Media has MOVED** (real conform) | **FCP7 XML**, AAF, FCPXML 1.10 | the only three that relink. **DRT, OTIO and EDL all fail** |
25
+
26
+ The trap is that DRT looks like the obvious choice — it is native and it is the
27
+ only format that survives a rich cut intact — and it is the *worst* choice for
28
+ both of the other two jobs. It re-imports its media every time, and it cannot
29
+ relink media that has moved at all.
30
+
31
+ **For an iterative loop, use FCP7 XML (or AAF) with:**
32
+
33
+ ```python
34
+ options = {
35
+ "timelineName": f"CUT_v{iteration:03d}", # MUST be unique per import
36
+ "importSourceClips": False, # reuse the pool, do not re-import
37
+ "sourceClipsFolders": [pool.GetRootFolder()],
38
+ }
39
+ timeline = pool.ImportTimelineFromFile(path, options)
40
+ ```
41
+
42
+ Verified: five consecutive imports, all succeeded, source in-points preserved
43
+ exactly, and **no media duplicated** — the media pool grew by exactly one item
44
+ per import, which is the imported timeline itself, not another copy of the
45
+ footage.
46
+
47
+ ## The traps, each measured
48
+
49
+ ### A repeated `timelineName` silently stops working
50
+
51
+ Importing the same file three times with the *same* `timelineName`:
52
+
53
+ | import | result |
54
+ | --- | --- |
55
+ | 1 | succeeded, timeline `ITER_FIXED` |
56
+ | 2 | **returned None** |
57
+ | 3 | **returned None** |
58
+
59
+ No error, no exception — `ImportTimelineFromFile` just returns None once the
60
+ name is taken. An iterative loop that reuses one name works exactly once and
61
+ then quietly does nothing, which is the worst possible failure for an automated
62
+ edit cycle. Make the name unique per iteration.
63
+
64
+ ### DRT ignores `timelineName` and re-imports the media
65
+
66
+ DRT is the native format and the obvious first choice, but it behaves
67
+ differently on import, in ways the documentation states but that are easy to
68
+ miss:
69
+
70
+ - `timelineName`, `importSourceClips` and `sourceClipsFolders` are **all invalid
71
+ for DRT**. Passing them gets you a failure that says nothing about the format.
72
+ - The imported timeline is named after the **file**, not the timeline inside it,
73
+ and repeated imports auto-uniquify: `iter`, `iter 2`, `iter 3`.
74
+ - Because `importSourceClips` cannot be set to False, each DRT import **adds
75
+ another copy of the source media** to the pool. Measured: three DRT imports
76
+ grew the pool by four items, against one per import for the XML route.
77
+
78
+ So DRT is excellent for a one-shot hand-off and wrong for a loop that runs a
79
+ hundred times.
80
+
81
+ ### FCPXML 1.10 exports a *directory*, not a file
82
+
83
+ `Timeline.Export(path, EXPORT_FCPXML_1_10, EXPORT_NONE)` returns True and
84
+ creates a **bundle directory** at `path` containing `Info.fcpxml` — the
85
+ `.fcpxmld` shape Final Cut uses. Consequences:
86
+
87
+ - `path.stat().st_size` reports the directory inode (96 bytes here), so a
88
+ size check reads it as a near-empty export.
89
+ - `ImportTimelineFromFile(path)` fails, because the path is a directory.
90
+
91
+ Point the import at the member instead, and the format round-trips exactly:
92
+
93
+ ```python
94
+ inner = next(Path(export_path).glob("*.fcpxml")) # Info.fcpxml
95
+ pool.ImportTimelineFromFile(str(inner), options)
96
+ ```
97
+
98
+ This is why the first round-trip run reported FCPXML 1.10 as import-failed in
99
+ both modes. It was the harness, not the format.
100
+
101
+ ### OTIO with `importSourceClips: False` arrives fully offline
102
+
103
+ Every other format relinks against the existing pool. OTIO does not: with
104
+ `reuse_pool` the timeline rebuilds with correct structure and **all four items
105
+ offline**, in both modes. With `importSourceClips: True` and a `sourceClipsPath`
106
+ it links correctly. If you want OTIO, re-import the sources.
107
+
108
+ ## What each format actually keeps
109
+
110
+ The flat single-track round trip found every format frame-exact — because a flat
111
+ cut of untouched clips gives them nothing to disagree about. On a real cut (two
112
+ video tracks, audio, a per-item transform, clip colours, flags, item and timeline
113
+ markers) they diverge sharply. Identical in both modes:
114
+
115
+ | format | cut | tracks | transform | colour | flags | item markers | timeline markers |
116
+ | --- | --- | --- | --- | --- | --- | --- | --- |
117
+ | **drt** | kept | kept | **kept** | **kept** | kept | **kept** | kept |
118
+ | fcpxml_1_10 | kept | **lost an audio track** | kept | lost | kept | lost | lost |
119
+ | fcp7xml | kept | kept | lost **only FlipX** | lost | kept | lost | lost |
120
+ | aaf | kept | kept | **lost: all reset to defaults** | lost | kept | lost | kept |
121
+ | otio | **lost: source** | kept | kept | lost | lost | kept | kept |
122
+ | edl | **lost** | **1v1a** | n/a | lost | lost | lost | lost |
123
+
124
+ Two details worth having:
125
+
126
+ - **AAF resets the entire transform to defaults**, while **FCP7 XML preserves
127
+ zoom, pan and rotation exactly** and drops only `FlipX`. A whole-dict
128
+ comparison called both "lost", which is the same word for "carries geometry"
129
+ and "does not". Crop comes back as 39.999936 against 40.0 — that is unit
130
+ round-tripping, not loss.
131
+ - **FCPXML 1.10 silently drops an audio track** (2v2a in, 2v1a out).
132
+
133
+ ## Relinking media that has moved
134
+
135
+ The test that matters for conform: build the cut, export it, **rename the media
136
+ directory** so every baked-in path is dead, then import in a fresh project with
137
+ `sourceClipsPath` pointing at the new location. Identical in both modes:
138
+
139
+ | format | relinked | cut still frame-exact |
140
+ | --- | --- | --- |
141
+ | fcp7xml | **3/3** | yes |
142
+ | aaf | **3/3** | yes |
143
+ | fcpxml_1_10 | **3/3** | yes |
144
+ | drt | **0/3** — items stay pointing at the dead path | yes (but offline) |
145
+ | edl | **0/3** | yes (but offline) |
146
+ | otio | **import returned None** — fails outright | — |
147
+
148
+ `sourceClipsPath` is not valid for DRT, so DRT has no mechanism to be told where
149
+ the media went. EDL carries reel names rather than paths and did not resolve
150
+ either. OTIO did not merely fail to relink — the import returned `None`.
151
+
152
+ Note that "frame-exact" stays true even where nothing relinked: the *cut*
153
+ survives offline. That is worth knowing, because it means a failed relink leaves
154
+ a correct timeline you can relink by other means, rather than a wrong one.
155
+
156
+ ## Format selection
157
+
158
+ All are frame-exact in both modes; these are the tie-breakers. Payload sizes are
159
+ for the same four-item test timeline.
160
+
161
+ | format | size | links from pool | name controllable | notes |
162
+ | --- | --- | --- | --- | --- |
163
+ | **fcp7xml** | 42 KB | yes | yes | the recommended loop format |
164
+ | **aaf** | 315 KB | yes | yes | same behaviour, 7x the payload |
165
+ | **edl** | 512 B | yes | yes | astonishingly, frame-exact — see below |
166
+ | drt | 19 KB | n/a (always re-imports) | no | native; one-shot hand-off |
167
+ | otio | 85 KB | **no — offline** | yes | needs `importSourceClips: True` |
168
+ | fcpxml_1_10 | 3 KB | yes | yes | writes a bundle directory |
169
+
170
+ **On EDL:** 512 bytes round-tripped frame-exactly with media linked, which is not
171
+ what EDL's reputation suggests. It works here because the test media carries
172
+ embedded timecode (`-timecode 01:00:00:00`) and the sources relink by name. EDL
173
+ carries no effects, no multiple tracks, and no speed changes — so this result
174
+ means "EDL is sufficient for a flat single-track conform of timecoded media",
175
+ not "EDL is a good interchange format". Do not generalise it to a real cut.
176
+
177
+ ## The whole loop, headless
178
+
179
+ ```bash
180
+ # 1. one Resolve, no UI, and confirm nothing else holds the singleton
181
+ python scripts/resolve_headless.py guard
182
+ python scripts/resolve_headless.py start
183
+ ```
184
+
185
+ ```python
186
+ # 2. import media and build the initial cut through the API, not a file.
187
+ # AppendToTimeline with explicit clipInfo dicts is frame-exact and needs no
188
+ # interchange format at all.
189
+ clips = resolve.GetMediaStorage().AddItemListToMediaPool(paths)
190
+ timeline = pool.CreateEmptyTimeline("CUT_v001")
191
+ project.SetCurrentTimeline(timeline)
192
+ pool.AppendToTimeline([
193
+ {"mediaPoolItem": clips[0], "startFrame": 0, "endFrame": 191},
194
+ {"mediaPoolItem": clips[1], "startFrame": 24, "endFrame": 71},
195
+ ])
196
+ ```
197
+
198
+ Two things about that call, both measured and both able to waste a session:
199
+
200
+ - **`endFrame` is inclusive-of-the-last-frame here** — pass `frames` rather than
201
+ `frames - 1` and you append nothing.
202
+ - **Do not build a repeated clip with `CreateTimelineFromClips([clip] * n)`.** It
203
+ deduplicates identical MediaPoolItem references: fifteen copies produced a
204
+ one-copy timeline, returned a valid Timeline, and reported no error. Use
205
+ `AppendToTimeline` with one dict per instance, and assert the resulting frame
206
+ count.
207
+
208
+ ```python
209
+ # 3. iterate: export, mutate outside Resolve, re-import under a NEW name
210
+ timeline.Export(str(path), resolve.EXPORT_FCP_7_XML, resolve.EXPORT_NONE)
211
+ ... # edit the XML however you like
212
+ pool.ImportTimelineFromFile(str(path), {
213
+ "timelineName": f"CUT_v{n:03d}",
214
+ "importSourceClips": False,
215
+ "sourceClipsFolders": [pool.GetRootFolder()],
216
+ })
217
+ ```
218
+
219
+ ```bash
220
+ # 4. done
221
+ python scripts/resolve_headless.py stop
222
+ ```
223
+
224
+ ## Rules that apply to the whole loop
225
+
226
+ - **Never call `SaveProject()` without checking the project name.** On the
227
+ never-saved `Untitled Project` it blocks the entire application — in both
228
+ modes — and needs a force-kill. Use
229
+ `src/utils/project_cleanup.py:save_project_if_safe`.
230
+ - **Verify structure, not return values.** Every finding above involved a call
231
+ that returned success. After building or importing a cut, read the item count
232
+ and each item's `GetLeftOffset()` back and compare against what you intended.
233
+ - **Teardown order is `CloseProject` → `LoadProject(something named)` →
234
+ `DeleteProject`.** `CloseProject` is what releases the session lock, and
235
+ landing on the unsaveable default project is what raises the next modal.
236
+ - Capability is identical headless, but **stability under long renders is not
237
+ established** — see the limits section in
238
+ [headless-cli.md](../reference/headless-cli.md).
@@ -12,7 +12,7 @@ that none exists).
12
12
 
13
13
  **Verified on:** DaVinci Resolve Studio 21.0.0
14
14
 
15
- **Totals:** 20 missing capabilities, 13 bugs / unreliable behaviors.
15
+ **Totals:** 21 missing capabilities, 20 bugs / unreliable behaviors.
16
16
 
17
17
  The authoritative source is the runtime-queryable `api_truth` ledger
18
18
  (`resolve_control api_truth "<query>"`); this document is generated from
@@ -41,6 +41,13 @@ Python bridge fabricates a callable for any attribute name (see the
41
41
  Functionality that exists in the Resolve UI but has no scripting API
42
42
  equivalent, blocking full automation.
43
43
 
44
+ ### Project.SetSetting('timelinePlaybackFrameRate')
45
+
46
+ - **Object:** `Project`
47
+ - **Behavior:** Returns False for every value form tried (string, int, float), both before and after a timeline exists, so the playback frame rate cannot be set from the API at all. Reported by a community contributor against Resolve Studio while assembling a vertical timeline (PR #99).
48
+ - **Workaround / current handling:** Ask the user to set it in Project Settings > Master Settings > Playback frame rate as a SETUP step, before any timeline exists. Read it back to confirm; do not report it as set on the strength of the call alone.
49
+ - **Tags:** project-settings, silent-failure, timeline
50
+
44
51
  ### Timeline.GetTimelineByName
45
52
 
46
53
  - **Object:** `Project`
@@ -226,9 +233,47 @@ values, or automation-hostile modal prompts.
226
233
 
227
234
  - **Object:** `ProjectManager`
228
235
  - **Signature:** `(projectName) -> bool`
229
- - **Behavior:** Returns False (no deletion) when the target project is, or recently was, the current project, and is flaky on the first attempt — so a single bool() call leaves the project undeleted with no useful error.
230
- - **Workaround / current handling:** Load/close away from the target first, then retry; use src/utils/project_cleanup.py:delete_project_safely.
231
- - **Tags:** unreliable-return, project, flaky
236
+ - **Behavior:** Returns False (no deletion) when the target project is, or recently was, the current project, and is flaky on the first attempt — so a single bool() call leaves the project undeleted with no useful error. CloseProject reliably releases it: a delete that had failed six times in a row, a second apart, succeeded on the first attempt after one. Switching away with LoadProject is NOT reliable — it left the delete failing permanently after a heavily-used project, yet succeeded for a project that had only been created and loaded. Whatever distinguishes the two (modification? an open timeline?) is not established, so LoadProject-away cannot be depended on.
237
+ - **Workaround / current handling:** CloseProject the target FIRST (that is what releases it), then LoadProject some named project so the session is not left on the unsaveable 'Untitled Project' fallback, then delete. Use src/utils/project_cleanup.py:delete_project_safely, which does exactly that.
238
+ - **Tags:** unreliable-return, project, flaky, session-lock
239
+
240
+ ### ProjectManager.GetCurrentDatabase
241
+
242
+ - **Object:** `ProjectManager`
243
+ - **Signature:** `() -> {DbType, DbName}`
244
+ - **Behavior:** Returns None when Resolve has come up without attaching to a project database — a state it reaches after an unclean shutdown, and does not recover from on its own. In that state the application looks entirely healthy: it accepts scripting connections, and GetProductName, GetVersionString, GetCurrentPage and GetCurrentProject all answer normally. But CreateProject and LoadProject return False indefinitely, SaveProject returns None, and some calls block forever in the scripting transport rather than returning at all. Observed headless on Studio 19.1.3.7 after force-killing a wedged instance; the replacement took 2m05s to become scriptable and came up with no database.
245
+ - **Workaround / current handling:** Treat a non-None GetCurrentDatabase() as the liveness check, not a successful connection — every cheaper check passes in the wedged state. `resolve_control runtime_mode` reports `database_attached` for exactly this. There is no repair: quit and restart Resolve.
246
+ - **Tags:** project, database, headless, silent-failure, startup
247
+
248
+ ### MediaPool.ImportTimelineFromFile
249
+
250
+ - **Object:** `MediaPool`
251
+ - **Signature:** `(filePath, {importOptions}) -> Timeline`
252
+ - **Behavior:** Returns None — no error, no exception — when the requested `timelineName` already exists. Measured: importing one file three times with the same name succeeded once and returned None twice. An iterative loop that reuses a fixed name therefore works exactly once and then silently does nothing. DRT ignores importOptions entirely (timelineName, importSourceClips and sourceClipsFolders are all invalid for it): the timeline is named after the FILE, repeats auto-uniquify ('iter', 'iter 2', 'iter 3'), and because importSourceClips cannot be disabled each DRT import adds another copy of the source media to the pool. OTIO is the one format that will NOT relink from the pool — with importSourceClips=False its timeline rebuilds with correct structure and every item OFFLINE, in both GUI and headless.
253
+ - **Workaround / current handling:** For a repeatable loop use FCP7 XML or AAF with a UNIQUE timelineName per iteration plus importSourceClips=False and sourceClipsFolders=[root] — verified frame-exact over five consecutive imports with no media duplicated. Use DRT for one-shot hand-offs only. For OTIO, pass importSourceClips=True with a sourceClipsPath. See docs/guides/headless-edit-loop.md.
254
+ - **Tags:** timeline, import, interchange, silent-failure, conform
255
+
256
+ ### Timeline.Export(EXPORT_FCPXML_1_10)
257
+
258
+ - **Object:** `Timeline`
259
+ - **Signature:** `(fileName, EXPORT_FCPXML_1_10, EXPORT_NONE) -> bool`
260
+ - **Behavior:** Returns True and creates a BUNDLE DIRECTORY at the given path containing `Info.fcpxml`, rather than a file — the `.fcpxmld` shape Final Cut uses. Two consequences bite immediately: a `stat().st_size` check reads the directory inode (96 bytes here) and concludes the export is empty, and ImportTimelineFromFile on that path fails because it is a directory. Every other EXPORT_* type in this build writes a plain file, so code that treats them uniformly gets this one wrong. Pointed at the inner member, the format round-trips frame-exactly in both modes.
261
+ - **Workaround / current handling:** After exporting, check `Path(p).is_dir()` and import `next(Path(p).glob('*.fcpxml'))` instead. Do not size-check the export path itself.
262
+ - **Tags:** timeline, export, interchange, fcpxml, silent-failure
263
+
264
+ ### MediaPool.CreateTimelineFromClips
265
+
266
+ - **Object:** `MediaPool`
267
+ - **Behavior:** Fails with a bare 'Failed to create timeline from clip_infos' — naming nothing actionable — when the Media Pool's CURRENT folder is not the folder holding the clips, even though every clip_id passed is valid and resolvable. Reported against Resolve Studio in PR #99.
268
+ - **Workaround / current handling:** Call MediaPool.SetCurrentFolder() to the clips' bin before creating the timeline (media_pool set_current_folder). Valid ids are not sufficient.
269
+ - **Tags:** media-pool, timeline, unhelpful-error
270
+
271
+ ### TimelineItem.AddFusionComp / LoadFusionCompByName
272
+
273
+ - **Object:** `TimelineItem (media-backed clip)`
274
+ - **Behavior:** A Fusion composition created on a media clip through the API is not applied at render WHEN MEDIAOUT HAS NO PATH FROM MEDIAIN. The original blanket form of this entry — 'never applied at render' — was too broad and was corrected on 2026-08-02: a comp wired MediaIn -> Blur -> MediaOut, created entirely through the API on an ordinary media clip, DOES render. PSNR between the plain and Fusion renders of the same timeline was 22.7 dB (identical would be infinite), the file shrank 22.5 MB -> 14.8 MB as a blur should, and the output was frame-for-frame identical in GUI and headless. A first attempt that wired ONLY MediaOut -> Blur, leaving the Blur with no source, made the render job come back 'Failed' with an 887-byte file — so an unrooted graph does not merely get bypassed, it can take the render down. What still stands is the original observation for the configuration it actually tested, which is retained below and has NOT been re-measured: AddFusionComp() returns the comp, AddTool/Connect/SetInput all succeed, and the whole graph reads back correctly (GetCompCount 1, MediaOut1.Input wired to the new tool, StyledText returning the value just set) — but the rendered output is byte-for-byte the untouched source media. Verified live on Studio 19.1.3.7 with the strongest form of the test: MediaOut1 fed ONLY by a Text+, with no path from MediaIn at all, still rendered the unmodified clip. LoadFusionCompByName on the sole comp does not activate it either. Contrast InsertFusionTitleIntoTimeline, whose comp DOES render — text set via SetInput('StyledText') appears in the output — so this is specific to comps attached to media-backed clips, not to Fusion through the API generally.
275
+ - **Workaround / current handling:** Wire the graph so MediaOut descends from MediaIn — that is the difference between a comp that renders and one that is silently bypassed, and it is what made this look like 'Fusion never renders from the API'. Never leave a tool unrooted: a MediaOut fed by a tool with no source failed the render job outright. For text or effects over picture, insert a Fusion title/generator as its own timeline clip and set its Text+ (fusion_comp set_text_plus), rather than attaching a comp to the media clip. Note the destination track cannot be chosen from the API (see the Track Selector entry), so overlaying onto an existing clip's track is not currently reachable end-to-end. Building the comp in the Fusion page UI works; only the API-created comp is ignored.
276
+ - **Tags:** fusion, silent-failure, render
232
277
 
233
278
  ### Composition.Paste
234
279
 
@@ -291,3 +336,19 @@ values, or automation-hostile modal prompts.
291
336
  - **Workaround / current handling:** Normalize both arguments through the live maps before calling: src.utils.render_ids.render_format_id_from_formats and render_codec_id_from_codecs accept a description or an id and return the id.
292
337
  - **Reference:** [issue #59](https://github.com/samuelgursky/davinci-resolve-mcp/issues/59)
293
338
  - **Tags:** render, deliver, silent-failure, id-vs-label
339
+
340
+ ### ProjectManager.SaveProject
341
+
342
+ - **Object:** `ProjectManager`
343
+ - **Signature:** `() -> bool`
344
+ - **Behavior:** On the default, never-saved project named 'Untitled Project' this call CANNOT succeed — the project has no location and there is no SaveProjectAs to give it one — and the two modes fail differently. In the GUI it returns False. HEADLESS IT BLOCKS FOREVER: measured on a cold -nogui boot with the database verified attached immediately beforehand, no return after 45s, the client parked in Fusion::RemoteApp::WaitPkt. Resolve wants a Save-As dialog and waits for an answer that cannot arrive. It also degrades the instance: after the hung call was interrupted, SaveProject began returning None instantly on that instance. On a named project it is fine in both modes.
345
+ - **Workaround / current handling:** Guard it: only call SaveProject when GetCurrentProject().GetName() != 'Untitled Project'. There is nothing to save on the default project and the call cannot succeed, so skipping it loses nothing. Do NOT reach for headless to dodge the GUI's save dialog — headless turns that dialog into an unbounded hang that no human can clear.
346
+ - **Tags:** project, modal, headless, hang, silent-failure, unreliable-return
347
+
348
+ ### GalleryStillAlbum.ExportStills
349
+
350
+ - **Object:** `GalleryStillAlbum`
351
+ - **Signature:** `(galleryStills, folderPath, filePrefix, format) -> bool`
352
+ - **Behavior:** PANEL-dependent, not mode-dependent — and the distinction took three revisions of this entry to pin down, so the evidence is recorded rather than summarised. Across four controlled 92-probe sweeps (2 GUI, 2 headless, 2026-08-01) it returned False and wrote nothing in ALL FOUR, while Timeline.GrabStill() succeeded in all four — so it is not being handed an empty still. In one earlier GUI session it DID work, returning True and writing 2 files. The variable that differed is not the mode: it is whether the Gallery panel was visible on the Color page, which depends on the restored workspace layout and which the harness does not control. A headless session can never satisfy it, so in practice the call never works headless; a GUI session satisfies it only sometimes. Project.ExportCurrentFrameAsStill worked in all four runs in both modes.
353
+ - **Workaround / current handling:** Do not use ExportStills unattended in either mode — a GUI session is not sufficient, only a GUI session with the Gallery panel open. Use Project.ExportCurrentFrameAsStill for pixels (verified in both modes, four for four) or drp.extract_node_graphs for grades. If ExportStills must be used, have the user open Workspace > Gallery first and verify the written files rather than trusting the return.
354
+ - **Tags:** gallery, stills, headless, unreliable-return
@@ -0,0 +1,56 @@
1
+ # GUI vs headless probe matrix
2
+
3
+ 105 probes, 2 GUI run(s) x 2 headless run(s).
4
+
5
+ | verdict | count |
6
+ | --- | --- |
7
+ | parity | 86 |
8
+ | flaky | 0 |
9
+ | hang_headless | 0 |
10
+ | hang_gui | 0 |
11
+ | headless_degraded | 2 |
12
+ | gui_degraded | 0 |
13
+ | both_hang | 0 |
14
+ | both_failed | 14 |
15
+ | divergent | 3 |
16
+ | untested | 0 |
17
+
18
+ ## Coverage by category
19
+
20
+ | category | probes | parity | hang_headless | headless_degraded | gui_degraded | divergent | untested |
21
+ | --- | --- | --- | --- | --- | --- | --- | --- |
22
+ | app | 8 | 6 | 0 | 0 | 0 | 0 | 0 |
23
+ | audio | 3 | 3 | 0 | 0 | 0 | 0 | 0 |
24
+ | color | 9 | 9 | 0 | 0 | 0 | 0 | 0 |
25
+ | editorial | 5 | 0 | 0 | 2 | 0 | 1 | 0 |
26
+ | fusion | 2 | 2 | 0 | 0 | 0 | 0 | 0 |
27
+ | gallery | 4 | 3 | 0 | 0 | 0 | 0 | 0 |
28
+ | interchange | 5 | 5 | 0 | 0 | 0 | 0 | 0 |
29
+ | layout | 4 | 4 | 0 | 0 | 0 | 0 | 0 |
30
+ | lifecycle | 3 | 3 | 0 | 0 | 0 | 0 | 0 |
31
+ | longop | 4 | 1 | 0 | 0 | 0 | 0 | 0 |
32
+ | media_pool | 8 | 5 | 0 | 0 | 0 | 0 | 0 |
33
+ | pages | 7 | 7 | 0 | 0 | 0 | 0 | 0 |
34
+ | project_manager | 8 | 8 | 0 | 0 | 0 | 0 | 0 |
35
+ | project_settings | 5 | 5 | 0 | 0 | 0 | 0 | 0 |
36
+ | projectlevel | 4 | 0 | 0 | 0 | 0 | 2 | 0 |
37
+ | render | 6 | 6 | 0 | 0 | 0 | 0 | 0 |
38
+ | timeline | 12 | 11 | 0 | 0 | 0 | 0 | 0 |
39
+ | timeline_item | 8 | 8 | 0 | 0 | 0 | 0 | 0 |
40
+
41
+ ## Mode-dependent findings
42
+
43
+ > **Re-verify every row below in isolation before believing it.** Probes run in catalogue order against one shared fixture, so a probe near the end has ~90 destructive probes' worth of accumulated state behind it, and the two modes can drift apart for reasons that have nothing to do with the mode. Measured: five findings here — nested timelines, clip linking, take selectors, multi-job queues and render presets — all reproduced as mode differences in a full sweep and all came out **identical** when re-run with `--only`. Isolation is the test:
44
+
45
+ > ```
46
+ > python scripts/mode_matrix.py run --mode gui --out iso_gui.jsonl --only <probe>
47
+ > python scripts/mode_matrix.py run --mode headless --out iso_hl.jsonl --only <probe>
48
+ > ```
49
+
50
+ | probe | verdict | GUI | headless | note |
51
+ | --- | --- | --- | --- | --- |
52
+ | `editorial.nested_timeline` | headless_degraded | ok: 'True/1' | raised | succeeded with a UI, failed without one |
53
+ | `editorial.set_clips_linked` | headless_degraded | ok: True | none: None | succeeded with a UI, failed without one |
54
+ | `editorial.take_selector` | divergent | ok: 'False/0/None/None' | ok: 'None/None/None/None' | both returned, values differ |
55
+ | `projectlevel.multi_job_queue` | divergent | ok: '3/3' | ok: '0/0' | both returned, values differ |
56
+ | `projectlevel.render_preset_roundtrip` | divergent | ok: 'True/True' | ok: 'None/None' | both returned, values differ |