davinci-resolve-mcp 2.69.3 → 2.70.1
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 +171 -0
- package/README.md +16 -10
- package/docs/SKILL.md +89 -6
- package/docs/guides/headless-edit-loop.md +238 -0
- package/docs/reference/api-limitations.md +65 -4
- package/docs/reference/headless-capability-matrix.md +56 -0
- package/docs/reference/headless-cli.md +751 -0
- package/docs/reference/pixel-equality.md +11 -0
- package/docs/reference/roundtrip-fidelity.md +17 -0
- package/docs/reference/roundtrip-rich-fidelity.md +18 -0
- package/install.py +1 -1
- package/package.json +1 -1
- package/scripts/bisect_headless_hang.py +125 -0
- package/scripts/contact_sheet.py +209 -0
- package/scripts/doctor.py +87 -17
- package/scripts/headless_differential.py +578 -0
- package/scripts/install_resolve_bridge.py +174 -56
- package/scripts/mode_matrix.py +345 -0
- package/scripts/mode_matrix_worker.py +232 -0
- package/scripts/pixel_equality.py +402 -0
- package/scripts/relink_moved_media.py +267 -0
- package/scripts/render_stress.py +542 -0
- package/scripts/resolve_headless.py +225 -0
- package/scripts/roundtrip_matrix.py +434 -0
- package/scripts/roundtrip_rich.py +431 -0
- package/src/granular/common.py +1 -1
- package/src/server.py +92 -67
- package/src/utils/api_truth.py +302 -4
- package/src/utils/headless_differential.py +363 -0
- package/src/utils/mode_matrix.py +338 -0
- package/src/utils/probe_catalogue.py +796 -0
- package/src/utils/project_cleanup.py +61 -9
- package/src/utils/resolve_runtime.py +233 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,177 @@
|
|
|
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.1
|
|
6
|
+
|
|
7
|
+
Windows support for the free-edition in-app bridge, and the end of doctor.py's
|
|
8
|
+
macOS-only path assumptions. Reported in issue #106 by @kacemmosbah8-afk.
|
|
9
|
+
|
|
10
|
+
### The bug
|
|
11
|
+
|
|
12
|
+
`script_targets()` in `scripts/install_resolve_bridge.py` enumerated macOS and
|
|
13
|
+
Linux candidates only. There was no Windows branch at all, so the candidate list
|
|
14
|
+
came back empty on every Windows machine and the installer exited with:
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
No writable DaVinci Resolve Scripts/Utility folder found. Is Resolve installed?
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
on installs where Resolve was demonstrably present. Since the in-app bridge is
|
|
21
|
+
the *only* route to the free edition — external scripting is Studio-gated by
|
|
22
|
+
Blackmagic — Windows free-edition users had no working path to this server at
|
|
23
|
+
all. The bridge shipped in v2.68.0; it has been macOS/Linux-only that whole time.
|
|
24
|
+
|
|
25
|
+
Windows now targets the two Scripts/Utility trees Blackmagic documents, per-user
|
|
26
|
+
first because `%PROGRAMDATA%` typically needs an elevated prompt:
|
|
27
|
+
|
|
28
|
+
- `%APPDATA%\Blackmagic Design\DaVinci Resolve\Support\Fusion\Scripts\Utility`
|
|
29
|
+
- `%PROGRAMDATA%\Blackmagic Design\DaVinci Resolve\Fusion\Scripts\Utility`
|
|
30
|
+
|
|
31
|
+
Three traps came out of implementing that, each now pinned by a test:
|
|
32
|
+
|
|
33
|
+
- **The per-user tree carries a `Support` segment the all-users tree does not.**
|
|
34
|
+
They are not one layout under two roots; deriving either from the other lands
|
|
35
|
+
in a folder Resolve never scans.
|
|
36
|
+
- **Blackmagic's own README writes the per-user root as `%APPDATA%\Roaming\...`,
|
|
37
|
+
which is wrong** — `%APPDATA%` already *is* `...\AppData\Roaming`. Transcribing
|
|
38
|
+
it verbatim yields `AppData\Roaming\Roaming\...`: a real, creatable folder that
|
|
39
|
+
is silently never read.
|
|
40
|
+
- **Resolve does not create its `Fusion\Scripts` tree until a script is
|
|
41
|
+
installed**, so gating on that tree existing — or on its parent being writable
|
|
42
|
+
— skips a fresh free-edition install, the exact build the bridge exists for.
|
|
43
|
+
The candidates are gated on Resolve's product folder instead, mirroring how the
|
|
44
|
+
macOS sandbox container is handled.
|
|
45
|
+
|
|
46
|
+
`install()` no longer aborts when one target is unwritable. The `%PROGRAMDATA%`
|
|
47
|
+
tree needs elevation, and Windows `os.access(W_OK)` reports the read-only flag
|
|
48
|
+
rather than the ACL, so it cannot be screened out in advance — without this, the
|
|
49
|
+
newly added candidate would have crashed the installer on every non-elevated
|
|
50
|
+
Windows account *after* it had already succeeded into the per-user tree. Skips
|
|
51
|
+
are reported in `warnings`; only a clean sweep is fatal, and that error now names
|
|
52
|
+
the folders and the reason instead of asking whether Resolve is installed.
|
|
53
|
+
|
|
54
|
+
### The macOS framework-Python alarm was firing off macOS
|
|
55
|
+
|
|
56
|
+
`python_preflight()` looked for a framework Python under `/Library/Frameworks`,
|
|
57
|
+
a path that cannot exist on Windows or Linux — so it always found nothing and
|
|
58
|
+
emitted the macOS remediation, telling the reporter (running a working python.org
|
|
59
|
+
3.12.9) to install the Python they already had. Linux had the same false alarm.
|
|
60
|
+
The check is now macOS-only. The Lua canary still ships everywhere, so a genuine
|
|
61
|
+
enumeration failure stays diagnosable.
|
|
62
|
+
|
|
63
|
+
### doctor.py was macOS-only too
|
|
64
|
+
|
|
65
|
+
`scripts/doctor.py` hardcoded macOS defaults for `RESOLVE_APP`,
|
|
66
|
+
`RESOLVE_SCRIPT_API` and `RESOLVE_SCRIPT_LIB`. Run standalone — that is, without
|
|
67
|
+
the environment variables the npm/`install.py` flow injects — it reported four
|
|
68
|
+
`[FAIL]` lines naming a `.app` bundle and `fusionscript.so` on a Windows 11
|
|
69
|
+
machine that had neither, while `install.py` detected that same install
|
|
70
|
+
correctly. Two tables describing one thing, one of them platform-blind. Linux was
|
|
71
|
+
equally affected.
|
|
72
|
+
|
|
73
|
+
doctor now selects per-platform candidates, and picks the first that exists so
|
|
74
|
+
the reported path matches reality. macOS values are byte-identical to before.
|
|
75
|
+
A new `tests/test_doctor_paths.py` drift guard asserts every path doctor names
|
|
76
|
+
also appears in `install.py`'s `RESOLVE_PATHS`, so the two cannot diverge again.
|
|
77
|
+
Claude Desktop's config path is now MSIX-aware on Windows as well, matching the
|
|
78
|
+
issue #93 fix that `install.py` already had.
|
|
79
|
+
|
|
80
|
+
### Scope — what is and is not verified
|
|
81
|
+
|
|
82
|
+
The path construction is unit-tested, and a simulated Windows run exercises the
|
|
83
|
+
installer end to end. **No Windows hardware has confirmed that Resolve actually
|
|
84
|
+
lists the bridge from these folders.** The reporter verified the folders exist
|
|
85
|
+
and are writable; issue #104 is precedent that "the folder exists" and "Resolve
|
|
86
|
+
reads it" are different claims. The README and `docs/SKILL.md` say so plainly
|
|
87
|
+
rather than implying Windows is a supported, tested tier.
|
|
88
|
+
|
|
89
|
+
Suite: 2312 → 2336.
|
|
90
|
+
|
|
91
|
+
## What's New in v2.70.0
|
|
92
|
+
|
|
93
|
+
Headless (`-nogui`) Resolve, measured rather than assumed — and the finding
|
|
94
|
+
reverses this project's working belief about it.
|
|
95
|
+
|
|
96
|
+
### The measurement
|
|
97
|
+
|
|
98
|
+
A GUI run and a `-nogui` run of the same Resolve Studio 19.1.3.7 were probed
|
|
99
|
+
identically and differenced: 139 read-only API probes plus 13 write scenarios
|
|
100
|
+
covering pages, media pool, timeline editing, colour, Fusion comps, Gallery,
|
|
101
|
+
render-to-disk, interchange export, layout presets, playhead and project
|
|
102
|
+
settings. 238 paired observations.
|
|
103
|
+
|
|
104
|
+
**Zero capabilities worked with a UI and failed without one.** Render to disk,
|
|
105
|
+
AAF/EDL/FCPXML/DRT/OTIO export, `ExportCurrentFrameAsStill`, `GrabStill`, Fusion
|
|
106
|
+
comp create/delete, colour groups, and even UI layout presets all behave
|
|
107
|
+
identically headless.
|
|
108
|
+
|
|
109
|
+
**Scope, stated up front:** that is a result about *capability* and about
|
|
110
|
+
modals. It is **not** a stability result. The probe's render was 2 seconds of
|
|
111
|
+
640×360 H.264; long renders, JPEG 2000 decode out of DCP MXF, ProRes-into-MXF
|
|
112
|
+
writes and repeated jobs in one session are all untested, and there is a field
|
|
113
|
+
report of headless crashing *on completion of writes* for exactly that class of
|
|
114
|
+
job. The docs and the runtime guidance say so rather than letting the matrix
|
|
115
|
+
be read as a blanket endorsement.
|
|
116
|
+
|
|
117
|
+
The one difference actually found runs the other way, and it is the one an agent
|
|
118
|
+
cares about: **a GUI Resolve can raise a modal dialog no script can dismiss;
|
|
119
|
+
headless cannot.** The trigger is project switching, and the obvious defence fails —
|
|
120
|
+
`ProjectManager.SaveProject()` returns `False` for the default never-saved
|
|
121
|
+
`Untitled Project`, which is exactly the project that raises the prompt.
|
|
122
|
+
Headless returns the same `False` and switches anyway.
|
|
123
|
+
|
|
124
|
+
Two earlier notes are corrected: `ExportCurrentFrameAsStill` was recorded as a
|
|
125
|
+
headless-only failure and works headless; `ExportStills` was recorded the same
|
|
126
|
+
way and fails in *both* modes (its documented cause is Gallery panel visibility,
|
|
127
|
+
which no headless session and no panel-closed GUI session can satisfy).
|
|
128
|
+
|
|
129
|
+
### Fixed
|
|
130
|
+
|
|
131
|
+
- **`timeline(action="get_items_in_track")` returned object reprs, not items.**
|
|
132
|
+
The handler passed `GetItemListInTrack`'s result to `_ser`, which has no
|
|
133
|
+
TimelineItem branch and falls through to `str(obj)`, so callers got
|
|
134
|
+
`["<PyRemoteObject at 0x…>", …]` instead of clip data. The docstring's claim of
|
|
135
|
+
"full serialization of each item" was never true. The sibling `get_items`
|
|
136
|
+
takes the same params through the same `_track_selector` and calls the same API
|
|
137
|
+
method, so the two are now one handler and `get_items_in_track` is documented
|
|
138
|
+
as its alias — there was no richer per-item serializer to preserve. Thanks
|
|
139
|
+
[@billcarroll](https://github.com/billcarroll) (#105).
|
|
140
|
+
|
|
141
|
+
### Added
|
|
142
|
+
|
|
143
|
+
- `resolve_control(action="runtime_mode")` — is Resolve up, and does it have a
|
|
144
|
+
UI? Answers with no connection needed, and carries the mode-specific guidance
|
|
145
|
+
with it. **There is no API tell**: a headless instance returns a real page from
|
|
146
|
+
`GetCurrentPage()` and identical product/version strings, so this reads the
|
|
147
|
+
process argv, which is the only place `-nogui` appears. `headless` is `null`
|
|
148
|
+
when undeterminable — never read that as `false`.
|
|
149
|
+
- `resolve_control(action="launch", params={"headless": true})`, plus
|
|
150
|
+
`DAVINCI_RESOLVE_HEADLESS=1` to make auto-launch headless. Launching the other
|
|
151
|
+
mode while an instance is running returns `RESOLVE_MODE_CONFLICT` instead of
|
|
152
|
+
starting a second one that would fight the singleton.
|
|
153
|
+
- `src/utils/resolve_runtime.py` — mode detection and launch-command
|
|
154
|
+
construction. Headless launch runs the binary inside the app bundle, because
|
|
155
|
+
`open -a` hands the argument list to LaunchServices, which discards `-nogui`
|
|
156
|
+
and gives you a window with no error.
|
|
157
|
+
- `scripts/resolve_headless.py` — batch/CI entry point: `status`, `guard`,
|
|
158
|
+
`start`, `stop`, and `run -- <cmd>` which only stops what it started.
|
|
159
|
+
- `scripts/headless_differential.py` + `src/utils/headless_differential.py` —
|
|
160
|
+
the harness that produced the above, rerunnable after a Resolve upgrade.
|
|
161
|
+
- `docs/reference/headless-cli.md` — verified flags, environment variables,
|
|
162
|
+
launch/teardown recipes, singleton rules, and the flags found in the
|
|
163
|
+
application binary but deliberately *not* exercised.
|
|
164
|
+
- `docs/reference/headless-capability-matrix.md` — the generated differential.
|
|
165
|
+
- Three `api_truth` entries: the `SaveProject`/modal trap, the absence of any
|
|
166
|
+
headless API tell, and the corrected `ExportStills` reality.
|
|
167
|
+
|
|
168
|
+
### Validation
|
|
169
|
+
|
|
170
|
+
- Offline suite 2280 → 2305 (`tests/test_headless_runtime.py`, 25 tests).
|
|
171
|
+
- Live: full differential recorded in both modes on Resolve Studio 19.1.3.7,
|
|
172
|
+
scratch project created and deleted per run, GUI session restored afterwards.
|
|
173
|
+
Measured teardown 2.1s via `Quit()`, headless boot to scriptable under 3s,
|
|
174
|
+
scripting listener on TCP 15000 in both modes.
|
|
175
|
+
|
|
5
176
|
## What's New in v2.69.3
|
|
6
177
|
|
|
7
178
|
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
|
-
[](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
|
|
4
4
|
[](https://www.npmjs.com/package/davinci-resolve-mcp)
|
|
5
5
|
[](docs/reference/api-coverage.md)
|
|
6
6
|
[-blue.svg)](#server-modes)
|
|
@@ -51,15 +51,21 @@ python scripts/install_resolve_bridge.py
|
|
|
51
51
|
export DAVINCI_RESOLVE_BRIDGE=1 # opt-in; unset changes nothing
|
|
52
52
|
```
|
|
53
53
|
|
|
54
|
-
|
|
55
|
-
only when it finds one — Homebrew, pyenv and conda
|
|
56
|
-
detected, and the script silently never appears in the
|
|
57
|
-
installed alongside so you can tell that apart from a
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
54
|
+
On **macOS**, this requires a **framework Python** (python.org). Resolve
|
|
55
|
+
enumerates `.py` scripts only when it finds one — Homebrew, pyenv and conda
|
|
56
|
+
interpreters are not detected, and the script silently never appears in the
|
|
57
|
+
menu. A Lua canary is installed alongside so you can tell that apart from a
|
|
58
|
+
wrong folder.
|
|
59
|
+
|
|
60
|
+
Validated on free 21.0.3.7 and Studio 19.1.3.7, both macOS. **Windows paths were
|
|
61
|
+
added in v2.70.1 (issue #106) and have not yet been confirmed on Windows
|
|
62
|
+
hardware** — the installer targets the `%APPDATA%` and `%PROGRAMDATA%` script
|
|
63
|
+
folders Blackmagic documents, but whether Resolve lists the bridge from them is
|
|
64
|
+
unverified. Reports welcome.
|
|
65
|
+
|
|
66
|
+
This is the documented in-app path, not a licence circumvention, but Blackmagic
|
|
67
|
+
could close it — treat it as a supported-until-it-is-not tier. Loopback only,
|
|
68
|
+
HMAC-signed requests, one-use nonces.
|
|
63
69
|
|
|
64
70
|
## Local Control Panel
|
|
65
71
|
|
package/docs/SKILL.md
CHANGED
|
@@ -27,11 +27,17 @@ loopback listener. Install with `python scripts/install_resolve_bridge.py`, star
|
|
|
27
27
|
it from that menu, and set `DAVINCI_RESOLVE_BRIDGE=1`. Existing tool call sites
|
|
28
28
|
work unchanged. Two things to know when diagnosing it:
|
|
29
29
|
|
|
30
|
-
- Resolve lists `.py` scripts only when it can find a **framework
|
|
31
|
-
(python.org). Homebrew/pyenv/conda are not detected and the script
|
|
32
|
-
appears, with no error. The installer preflights this and ships a
|
|
33
|
-
which always lists, so "Python not detected" is distinguishable
|
|
34
|
-
folder".
|
|
30
|
+
- On macOS, Resolve lists `.py` scripts only when it can find a **framework
|
|
31
|
+
Python** (python.org). Homebrew/pyenv/conda are not detected and the script
|
|
32
|
+
simply never appears, with no error. The installer preflights this and ships a
|
|
33
|
+
Lua canary, which always lists, so "Python not detected" is distinguishable
|
|
34
|
+
from "wrong folder". The preflight is macOS-only — off macOS Resolve finds
|
|
35
|
+
Python by other means, and running the check there was a false alarm (#106).
|
|
36
|
+
- **Windows is unconfirmed.** The `%APPDATA%`/`%PROGRAMDATA%` script folders are
|
|
37
|
+
targeted as of v2.70.1, but no Windows machine has verified that Resolve lists
|
|
38
|
+
the bridge from them. If a user reports the menu entry missing on Windows, ask
|
|
39
|
+
whether the Lua canary lists — that separates "wrong folder" from "Python not
|
|
40
|
+
detected" there too.
|
|
35
41
|
- The in-Resolve runtime is a **copy taken at install time**. After changing the
|
|
36
42
|
repository, re-run the installer and then ask the running bridge to reload —
|
|
37
43
|
it re-imports from disk in place, so Resolve does not need restarting.
|
|
@@ -218,6 +224,81 @@ respect this hint pass it as `tool_choice={type:"tool", name:"media_analysis"}`
|
|
|
218
224
|
on the next API turn, hard-locking the agent into the correct next call. Hosts
|
|
219
225
|
that don't recognize the field ignore it — the flow is unchanged for them.
|
|
220
226
|
|
|
227
|
+
## Headless Resolve (`-nogui`)
|
|
228
|
+
|
|
229
|
+
Resolve runs without a UI, and it is **capability-identical** to a GUI session.
|
|
230
|
+
Measured across 238 paired observations on Studio 19.1.3.7 (see
|
|
231
|
+
`docs/reference/headless-cli.md` and the regenerable
|
|
232
|
+
`docs/reference/headless-capability-matrix.md`): **zero** capabilities work with
|
|
233
|
+
a UI and fail without one. Pages, render-to-disk, AAF/EDL/XML/DRT/OTIO export,
|
|
234
|
+
`ExportCurrentFrameAsStill`, Fusion comps, colour groups, layout presets and all
|
|
235
|
+
ordinary editorial behave identically.
|
|
236
|
+
|
|
237
|
+
**Capability is not stability, but stability is now partly measured too.** Ten
|
|
238
|
+
consecutive ProRes 422 HQ renders in one headless session, from a JPEG 2000 /
|
|
239
|
+
MXF OP1A source, completed with no crash, no death and no leak — marginally
|
|
240
|
+
faster and ~95 MB lighter than the same ten renders with a UI. What remains
|
|
241
|
+
untested is *sustained* encode over hours and the operator's own footage and
|
|
242
|
+
codec settings. Use headless freely for orchestration, edit, conform, analysis
|
|
243
|
+
and renders of that scale; qualify it on real footage before promising a
|
|
244
|
+
long-form or professional-container delivery, and keep a GUI fallback there.
|
|
245
|
+
|
|
246
|
+
**Headless is NOT immune to modal dialogs — it is worse.** It cannot *display* a
|
|
247
|
+
dialog, but Resolve still tries to raise one, and the call then never returns.
|
|
248
|
+
|
|
249
|
+
The canonical case, measured on a cold `-nogui` boot:
|
|
250
|
+
`ProjectManager.SaveProject()` on the default never-saved project named
|
|
251
|
+
`Untitled Project` **blocks forever** headless (no return after 45s, client
|
|
252
|
+
parked in `Fusion::RemoteApp::WaitPkt`), where the GUI merely returns `False`.
|
|
253
|
+
The project has no location and there is no `SaveProjectAs`, so Resolve wants a
|
|
254
|
+
Save-As dialog and waits for an answer that can never arrive. In the GUI a human
|
|
255
|
+
clears it in one click; headless nothing can.
|
|
256
|
+
|
|
257
|
+
**Never call `SaveProject()` without checking the project name first:**
|
|
258
|
+
|
|
259
|
+
```python
|
|
260
|
+
project = pm.GetCurrentProject()
|
|
261
|
+
if project is not None and project.GetName() != "Untitled Project":
|
|
262
|
+
pm.SaveProject() # safe: it has a location
|
|
263
|
+
# else: nothing to save, and headless this call blocks forever
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
`src/utils/project_cleanup.py:save_project_if_safe(pm)` does exactly this — use
|
|
267
|
+
it rather than calling `SaveProject` directly. And do **not** reach for headless
|
|
268
|
+
to dodge the GUI's save dialog; that trade makes a one-click interruption into a
|
|
269
|
+
dead session.
|
|
270
|
+
|
|
271
|
+
Rules:
|
|
272
|
+
|
|
273
|
+
- **Check the mode before any project switch.** `resolve_control(action="runtime_mode")`
|
|
274
|
+
→ `{running, headless, instances, database_attached, guidance}`. It needs no
|
|
275
|
+
connection.
|
|
276
|
+
- **`database_attached: false` means the instance is WEDGED — stop and restart it.**
|
|
277
|
+
Resolve can come up with no project database attached. It accepts connections
|
|
278
|
+
and answers product, version, page and current-project queries normally, so
|
|
279
|
+
every ordinary liveness check passes, while `CreateProject`/`LoadProject`
|
|
280
|
+
return False forever, `SaveProject` returns None, and some calls never return
|
|
281
|
+
at all. It does not recover on its own. Do not retry; quit and relaunch.
|
|
282
|
+
- **`headless` may be `null`.** That means "cannot be determined", not "has a UI".
|
|
283
|
+
Treat `null` like `false` — take the careful path — but do not report it as fact.
|
|
284
|
+
- **There is no API tell.** A headless instance returns a real page from
|
|
285
|
+
`GetCurrentPage()` and identical product/version strings. Anything that
|
|
286
|
+
inspects the `resolve` handle to guess the mode is guessing. `runtime_mode`
|
|
287
|
+
reads the process argv, which is the only place `-nogui` appears.
|
|
288
|
+
- **Launching:** `resolve_control(action="launch", params={"headless": true})`,
|
|
289
|
+
or set `DAVINCI_RESOLVE_HEADLESS=1` to make auto-launch headless. Launching
|
|
290
|
+
the other mode while an instance is already running returns
|
|
291
|
+
`RESOLVE_MODE_CONFLICT` rather than starting a second one — two Resolves fight
|
|
292
|
+
the singleton and have been observed to crash-loop rather than fail cleanly.
|
|
293
|
+
- **`instances` > 1 is a fault to report**, not a state to work around. Check for
|
|
294
|
+
a render node before starting anything.
|
|
295
|
+
- Teardown is `resolve_control(action="quit")`; it discards the open project
|
|
296
|
+
without prompting, which is what a batch process wants.
|
|
297
|
+
|
|
298
|
+
The one thing headless genuinely cannot do is anything that needs a *visible
|
|
299
|
+
panel* — `ExportStills` being the known case, and it fails in a panel-closed GUI
|
|
300
|
+
too.
|
|
301
|
+
|
|
221
302
|
## Local Control Panel
|
|
222
303
|
|
|
223
304
|
If the user asks to open, launch, or inspect the Resolve MCP control panel, run
|
|
@@ -1850,7 +1931,9 @@ timeline item returns `False` in Resolve. Use `get_node_graph` without a
|
|
|
1850
1931
|
|
|
1851
1932
|
**Gallery export requires the Gallery panel visible** — `ExportStills` only works
|
|
1852
1933
|
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.
|
|
1934
|
+
user to open it via Workspace menu if export fails. Measured to fail in *both*
|
|
1935
|
+
GUI (panel closed) and headless sessions, so a failure is not a reason to switch
|
|
1936
|
+
modes. For pixels use `Project.ExportCurrentFrameAsStill`, which works in both.
|
|
1854
1937
|
|
|
1855
1938
|
**Python version** — the only hard requirement is Python **3.10+** (the MCP SDK
|
|
1856
1939
|
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).
|