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 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
- [![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.1-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)
@@ -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
- Requires a **framework Python** (python.org). Resolve enumerates `.py` scripts
55
- only when it finds one — Homebrew, pyenv and conda interpreters are not
56
- detected, and the script silently never appears in the menu. A Lua canary is
57
- installed alongside so you can tell that apart from a wrong folder.
58
-
59
- Validated on free 21.0.3.7 and Studio 19.1.3.7. This is the documented in-app
60
- path, not a licence circumvention, but Blackmagic could close it treat it as a
61
- supported-until-it-is-not tier. Loopback only, HMAC-signed requests, one-use
62
- nonces.
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 Python**
31
- (python.org). Homebrew/pyenv/conda are not detected and the script simply never
32
- appears, with no error. The installer preflights this and ships a Lua canary,
33
- which always lists, so "Python not detected" is distinguishable from "wrong
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).