davinci-resolve-mcp 2.91.0 → 2.93.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,131 @@
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.93.0
6
+
7
+ `source_end` is a source frame again, and the guidance v2.91.0 shipped about WAV
8
+ frame rates was wrong. Both found by measuring rather than reasoning, live on
9
+ Studio 19.1.3.7 with synthetic media in a disposable project.
10
+
11
+ ### Fixed
12
+
13
+ - **`source_end` was `source_start + duration`, and that duration is a TIMELINE
14
+ duration.** So the sum was unit-mixed the moment the media and timeline rates
15
+ differed. Measured against the `endFrame` actually sent, it overshot by
16
+ **+24, +26, +108 and +149 frames** on a WAV counting at 24 in a 29.97 timeline
17
+ — and `extract_source_frame_ranges` builds pull ranges out of it, reporting
18
+ widths of 543 and 749 for clips that consume 435 and 600 source frames.
19
+ The direction was safe (a longer pull); the number was wrong, and anything
20
+ sizing an archive, a consolidation or a pull list inherited it.
21
+
22
+ It is now `round(GetSourceEndTime × source_fps)`. Seconds carry no frame-rate
23
+ assumption, so the product is a source frame by construction, and the field
24
+ stays **EXCLUSIVE** exactly as every caller already read it. Over 8 items in
25
+ both regimes it equals the `endFrame` sent every time, and it reproduces the
26
+ old value wherever the old value was already right — so no matched-rate
27
+ consumer moves, which the full suite confirms without a single existing
28
+ expectation changing. It falls back to the old sum only when the second-reader
29
+ or the rate is unreadable.
30
+
31
+ - **`GetSourceEndFrame` was the obvious candidate and it is not usable raw.**
32
+ Measured over 12 items, it is **exclusive when the source and timeline rates
33
+ match and inclusive when they differ** — off by one in exactly the case a
34
+ caller reaches for it. Not a media-type split either: the same WAV imported at
35
+ 29.97 into a 29.97 timeline reads exclusive like video, and only the mismatch
36
+ flips it. Building on it would have meant branching on a rate comparison the
37
+ code would first have to reconstruct.
38
+
39
+ ### Corrected
40
+
41
+ - **A WAV is not 24 fps.** v2.91.0's ledger entry said a WAV "carries no frame
42
+ rate, so Resolve falls back to 24" and told callers to treat one as 24 fps.
43
+ That is wrong. A WAV takes the **project's `timelineFrameRate` at import** and
44
+ freezes it: one 400.000 s file imported at 24 reads `FPS 24.0` /
45
+ `Duration 00:06:40:00`, the same file imported at 29.97 reads `29.97` /
46
+ `00:06:39:18`, and changing the project rate after import leaves the clip on
47
+ its original rate. So the trap is "the project moved after import", not "audio
48
+ is always 24" — and a WAV imported at 29.97 has no mismatch at all. Anyone who
49
+ followed the old advice on such a file would have converted a correct number
50
+ into a wrong one. Corrected in the ledger, the `resolve-rough-cut` traps table,
51
+ the `probe_timeline_structure` and `create_variant_from_ranges` action help,
52
+ and the helper docstrings: every site now says read the rate, never assume it.
53
+
54
+ ### Validation
55
+
56
+ - Suite: 2628 passed, 1 skipped (7 new cases covering both regimes, the rounding
57
+ boundaries, and each fallback). Static checks and drift guards clean.
58
+ - Live on **Studio 19.1.3.7**, ffmpeg-generated synthetic media, disposable
59
+ project deleted after each run. The first measurement pass was discarded and
60
+ redone: it zipped `AppendToTimeline`'s return against the request list, two
61
+ entries came back unreadable, and the resulting misalignment looked exactly
62
+ like reader noise. Every number above comes from appending one range at a time.
63
+ - **Not tested here:** Resolve 21.x, and retimed clips — there is no clip-speed
64
+ API to build one from, so whether the new route also fixes the retime case
65
+ (where the old arithmetic is wrong for the same reason) is untested.
66
+
67
+ ## What's New in v2.92.0
68
+
69
+ Two corrections to advice this project was giving confidently and wrongly, both
70
+ from issues filed by users who hit them.
71
+
72
+ ### Fixed
73
+
74
+ - **`PYTHON3HOME` satisfies the macOS bridge preflight — uv/pixi/conda need no
75
+ `sudo`.** The preflight demanded a *framework* Python and sent everyone else
76
+ to a system-wide python.org install, which managed machines often forbid.
77
+ `uv`, `pixi` and conda-forge ship no `--enable-framework` build at all, so
78
+ their users got the warning no matter what they did. Reported by @rusanivsky
79
+ in #143, who had free Resolve 21.0.4.5 serving the bridge on uv-managed
80
+ CPython 3.12.13 with no python.org Python on the machine.
81
+
82
+ Framework-ness was never the variable. In `fusionscript.so` — read here on
83
+ Studio 19.1.3.7, a January 2025 binary — every `Python.framework` reference is
84
+ Python **2.7**, and there is no `Python.framework/Versions/3` string anywhere.
85
+ Python 3 is found through `PYTHON3HOME`, else `/usr/local/bin/python3`, then
86
+ probed for `sys.prefix` and dlopened as `<prefix>/lib/libpython3.X.dylib`.
87
+ python.org installs work because that installer creates
88
+ `/usr/local/bin/python3` — verified here, where it is a symlink into
89
+ `Python.framework/Versions/3.11`. Homebrew (`/opt/homebrew`), pyenv, uv and
90
+ conda land in neither place, which is the whole of the "framework Pythons
91
+ only" folklore.
92
+
93
+ The preflight now accepts either route and reports both. `PYTHON3HOME` is read
94
+ with `launchctl getenv`, never `os.environ`: Resolve is GUI-launched and
95
+ inherits launchd's environment, so reading our own shell would report a hit in
96
+ exactly the case that does not work. A `PYTHON3HOME` exported in the shell but
97
+ absent from launchd is called out, because it is the natural thing to try. The
98
+ old advice is corrected in the Lua canary, the `BRIDGE_UNAVAILABLE`
99
+ remediation, both READMEs and `docs/SKILL.md`.
100
+
101
+ ### Added
102
+
103
+ - **Domain skills ask the build what it cannot do.** v2.89.0 taught
104
+ `get_version` to report a connected build's missing surfaces, but only the
105
+ session skill ever asked — an agent entering through
106
+ `/timeline_edit_workflow` or `/color_grade_workflow` got identical guidance
107
+ whatever it was attached to. That is the hole @magwa101 fell into on DR 21 in
108
+ #132. `resolve-edit`, `resolve-color`, `resolve-conform` and
109
+ `resolve-media-analysis` now name the gated surfaces in their own domain, with
110
+ the floor and what to do instead, sourced from `resolve_versions.VERSION_GATES`
111
+ rather than prose. Each section also has to say that an empty list means
112
+ nothing *recorded* is missing, that probes use `name in dir(obj)` and never
113
+ bare `hasattr` (which returns `True` for every name on a Resolve object), and
114
+ that *gated* is not *absent* — clip speed is unreachable on every build and no
115
+ upgrade will help.
116
+ - `tests/test_skill_version_gates.py` fails when a skill quotes a floor the
117
+ ledger disagrees with. Confirmed it bites by mis-stating a floor and watching
118
+ it fail, not by trusting a green run.
119
+
120
+ ### Validation
121
+
122
+ - Suite: 2621 passed, 1 skipped. Static checks and drift guards clean.
123
+ - The binary strings and the `/usr/local/bin/python3` mechanism were confirmed
124
+ here on Studio 19.1.3.7. **Not reproduced here:** the live positive on free
125
+ 21.0.4.5 with uv Python — that is the reporter's, and is labelled as such in
126
+ the code comment.
127
+ - No Resolve scripting behavior changed; the bridge change is installer
128
+ preflight and advice text.
129
+
5
130
  ## What's New in v2.91.0
6
131
 
7
132
  A timeline item's source frames are counted in the **media's** frame rate, not
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  English | [简体中文](README.zh-CN.md)
4
4
 
5
- [![Version](https://img.shields.io/badge/version-2.91.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-2.93.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
6
6
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
7
7
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
8
8
  [![Tools](https://img.shields.io/badge/MCP%20Tools-34%20(353%20full)-blue.svg)](#server-modes)
@@ -59,11 +59,21 @@ transport tried, so a bridge that stops answering reports its own fault rather
59
59
  than quietly falling back to another transport. Use it when the bridge is the
60
60
  path you intend to depend on.
61
61
 
62
- On **macOS**, this requires a **framework Python** (python.org). Resolve
63
- enumerates `.py` scripts only when it finds one — Homebrew, pyenv and conda
64
- interpreters are not detected, and the script silently never appears in the
65
- menu. A Lua canary is installed alongside so you can tell that apart from a
66
- wrong folder.
62
+ On **macOS**, Resolve looks for Python 3 in exactly two places: the
63
+ `PYTHON3HOME` environment variable, then `/usr/local/bin/python3`. Homebrew,
64
+ pyenv, uv and conda land in neither, so the script silently never appears in the
65
+ menu. A python.org install works because its installer creates
66
+ `/usr/local/bin/python3` — but you do not need one: point Resolve at the
67
+ interpreter you already have, no `sudo` required.
68
+
69
+ ```bash
70
+ launchctl setenv PYTHON3HOME "$(python3 -c 'import sys; print(sys.prefix)')"
71
+ ```
72
+
73
+ Use `launchctl setenv`, not `export` — Resolve is launched from the Dock and
74
+ never sees your shell's environment. Restart Resolve afterwards. A Lua canary is
75
+ installed alongside so you can tell "Python not detected" apart from a wrong
76
+ folder.
67
77
 
68
78
  Validated on free 21.0.3.7 and Studio 19.1.3.7, both macOS. The Windows paths
69
79
  added in v2.70.1 (issue #106) shipped unverified; reports on free 21.0.1.11
@@ -72,7 +82,7 @@ installing, listing and serving from **both** `%PROGRAMDATA%` and `%APPDATA%` on
72
82
  Windows 11, so those paths are now confirmed rather than assumed. Linux is
73
83
  confirmed as well: a report on free 20.3.2.9 (issue #129, Fedora 43) shows the
74
84
  bridge installing to `~/.local/share/DaVinciResolve/Fusion/Scripts/Utility`,
75
- listing against the system Python — no framework-Python requirement on Linux
85
+ listing against the system Python — Linux has none of this discovery problem
76
86
  and serving end-to-end. No platform now rests on an assumption: macOS was
77
87
  validated directly, Windows and Linux on user reports.
78
88
 
package/README.zh-CN.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [English](README.md) | 简体中文
4
4
 
5
- [![Version](https://img.shields.io/badge/version-2.91.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-2.93.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
6
6
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
7
7
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
8
8
  [![Tools](https://img.shields.io/badge/MCP%20Tools-34%20(353%20full)-blue.svg)](#服务器模式)
@@ -12,7 +12,7 @@
12
12
  [![Python](https://img.shields.io/badge/python-3.10+-green.svg)](https://www.python.org/downloads/)
13
13
  [![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://opensource.org/licenses/MIT)
14
14
 
15
- > 本翻译对应 v2.91.0 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
15
+ > 本翻译对应 v2.93.0 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
16
16
 
17
17
  一个 Model Context Protocol (MCP) 服务器,让 AI 助手通过官方脚本 API 控制 DaVinci Resolve Studio(达芬奇)。它提供完整的 API 覆盖,外加带护栏的工作流助手,涵盖剪辑、媒体池整理、渲染设置、审阅标记、调色、Fusion、Fairlight、项目生命周期任务、扩展开发,以及不碰源媒体的媒体分析。
18
18
 
@@ -53,9 +53,15 @@ python scripts/install_resolve_bridge.py
53
53
  `DAVINCI_RESOLVE_BRIDGE=1` 则是*强制*走桥接:它会成为唯一尝试的传输方式,因此桥接一旦停止响应会
54
54
  直接报错,而不会悄悄回退到其他传输。当你明确要依赖桥接时使用它。
55
55
 
56
- 在 **macOS** 上,这需要 **framework 版 Python**(python.org 官网安装包)。Resolve 只有找到 framework Python 才会枚举 `.py` 脚本——Homebrew、pyenv、conda 的解释器都不被识别,脚本会静默地不出现在菜单里。安装时会顺带装一个 Lua 金丝雀脚本,帮你区分"Python 不对"和"目录放错"。
56
+ 在 **macOS** 上,Resolve 只在两个位置查找 Python 3:环境变量 `PYTHON3HOME`,然后是 `/usr/local/bin/python3`。Homebrew、pyenv、uv、conda 都不装在这两处,因此脚本会静默地不出现在菜单里。python.org 安装包之所以有效,是因为它的安装程序会创建 `/usr/local/bin/python3`——但你并不需要它:直接把 Resolve 指向你已有的解释器即可,无需 `sudo`。
57
57
 
58
- 已在免费版 21.0.3.7 和 Studio 19.1.3.7 上验证(均为 macOS)。v2.70.1(issue #106)加入的 Windows 路径发布时未经验证;后续免费版 21.0.1.11(issue #109)和免费版 21.0.3.7(issue #112)的用户报告证实,Windows 11 上桥接在 `%PROGRAMDATA%` 和 `%APPDATA%` **两处**都能安装、列出并正常服务,这些路径现在是已证实而非假设。Linux 同样已获证实:免费版 20.3.2.9 的用户报告(issue #129,Fedora 43)显示桥接可安装到 `~/.local/share/DaVinciResolve/Fusion/Scripts/Utility`,用系统 Python 就能直接枚举脚本(Linux 不需要 framework 版 Python),并能端到端正常服务。现在没有任何平台停留在假设上:macOS 为本项目直接验证,Windows 和 Linux 来自用户报告。
58
+ ```bash
59
+ launchctl setenv PYTHON3HOME "$(python3 -c 'import sys; print(sys.prefix)')"
60
+ ```
61
+
62
+ 必须用 `launchctl setenv` 而不是 `export`——Resolve 从 Dock 启动,看不到你 shell 的环境变量。之后重启 Resolve。安装时会顺带装一个 Lua 金丝雀脚本,帮你区分"Python 未被检测到"和"目录放错"。
63
+
64
+ 已在免费版 21.0.3.7 和 Studio 19.1.3.7 上验证(均为 macOS)。v2.70.1(issue #106)加入的 Windows 路径发布时未经验证;后续免费版 21.0.1.11(issue #109)和免费版 21.0.3.7(issue #112)的用户报告证实,Windows 11 上桥接在 `%PROGRAMDATA%` 和 `%APPDATA%` **两处**都能安装、列出并正常服务,这些路径现在是已证实而非假设。Linux 同样已获证实:免费版 20.3.2.9 的用户报告(issue #129,Fedora 43)显示桥接可安装到 `~/.local/share/DaVinciResolve/Fusion/Scripts/Utility`,用系统 Python 就能直接枚举脚本(Linux 完全没有这套查找问题),并能端到端正常服务。现在没有任何平台停留在假设上:macOS 为本项目直接验证,Windows 和 Linux 来自用户报告。
59
65
 
60
66
  注意:桥接在服务期间会一直占用端口。v2.70.3 之前,Windows 上的桥接可能在 Resolve 退出后存活,挡住下一个会话的监听器;如果你用的是旧版本且桥接不响应了,检查是否有残留的 `fuscript.exe` 还占着端口。
61
67
 
package/docs/SKILL.md CHANGED
@@ -30,12 +30,17 @@ scripting is unavailable, with no environment variable needed.
30
30
  tried, so its faults surface directly instead of degrading to another path.
31
31
  Existing tool call sites work unchanged. Two things to know when diagnosing it:
32
32
 
33
- - On macOS, Resolve lists `.py` scripts only when it can find a **framework
34
- Python** (python.org). Homebrew/pyenv/conda are not detected and the script
35
- simply never appears, with no error. The installer preflights this and ships a
36
- Lua canary, which always lists, so "Python not detected" is distinguishable
37
- from "wrong folder". The preflight is macOS-only off macOS Resolve finds
38
- Python by other means, and running the check there was a false alarm (#106).
33
+ - On macOS, Resolve finds Python 3 through **`PYTHON3HOME`, then
34
+ `/usr/local/bin/python3`** and nowhere else, so Homebrew/pyenv/uv/conda
35
+ interpreters simply never appear, with no error. python.org installs work
36
+ because that installer creates `/usr/local/bin/python3`; framework-ness itself
37
+ is not the variable (#143). The sudo-free fix is
38
+ `launchctl setenv PYTHON3HOME "$(python3 -c 'import sys; print(sys.prefix)')"`
39
+ — `launchctl`, not `export`, because Resolve is GUI-launched and inherits
40
+ launchd's environment. The installer preflights both routes and ships a Lua
41
+ canary, which always lists, so "Python not detected" is distinguishable from
42
+ "wrong folder". The preflight is macOS-only — off macOS Resolve finds Python
43
+ by other means, and running the check there was a false alarm (#106).
39
44
  - **Windows: both script folders confirmed.** `%PROGRAMDATA%` (#109) and
40
45
  `%APPDATA%` (#112) have each been shown serving the bridge on Windows 11 free
41
46
  builds. If a user reports the menu entry missing on Windows, ask whether the
@@ -382,12 +382,12 @@ values, or automation-hostile modal prompts.
382
382
  - **Workaround / current handling:** Verify source-side placement with GetLeftOffset, which is exact. Treat GetSourceStartFrame as approximate, and never diff it against a sent startFrame to decide whether a clip landed right. Scope: placement at 100% speed. On a retimed clip the two read DIFFERENT domains — GetLeftOffset is warped (position / speed), GetSourceStartFrame is true source — see the retime entry's witness calibration before comparing them.
383
383
  - **Tags:** off-by-one, unreliable-return, timeline, conform, verify
384
384
 
385
- ### TimelineItem.GetSourceStartFrame on an AUDIO item (media-rate frames; 24 fps for WAV)
385
+ ### TimelineItem.GetSourceStartFrame on an AUDIO item (media-rate frames; a WAV freezes the PROJECT rate at import)
386
386
 
387
387
  - **Object:** `TimelineItem`
388
388
  - **Signature:** `() -> int # source frame, counted in the MEDIA's frame rate`
389
- - **Behavior:** The value is counted in the source MEDIA's own frame rate, not the timeline's and a WAV carries no frame rate, so Resolve falls back to 24 fps. Reading it at the timeline rate lands minutes away from the real position in the file. Verified live on Studio 21.0.3.7 (2026-08-09, 29.97 fps timeline): a ZOOM0028.WAV item reported source_start 56871, which is 56871 / 24 = 2369.6 s into the file, NOT the 1897.6 s a 29.97 fps reading gives — a 471.9 s (7 min 52 s) error. Nothing looks wrong, because timeline probe_timeline_structure derives source_end as source_start + timeline_duration: the start/end pair stays internally consistent whatever rate you assume. VIDEO items are NOT affected — two 29.97 fps items (KR020007.MOV, IMG_0001.mov) on the same timeline reported source frames in their own, matching rate, confirmed against ffprobe durations and span arithmetic. This is the read-side twin of the AppendToTimeline mixed-fps entry below: that one is about writing source frames whose rate differs from the timeline's, this one about reading them back and not knowing which rate they are in. The rate was pinned by regression, not assumed: across 12 items of the same WAV, GetSourceStartFrame advances at 24.000 fps against the item's own GetSourceStartTime (24.0000/24.0007/23.9995 over spans up to 22 minutes). The same measurement exposed a second unit trap: on an AUDIO item GetLeftOffset advances at 29.970 — the TIMELINE rate — so the two readers describe the same edit point in DIFFERENT frame spaces (60687 vs 75784 for one item). On video they share the source space. Caveat on the absolute zero: Resolve's model of this file is 133003 frames (Duration 01:32:21:19 at 24 fps = 5541.79 s) while its true PCM length is 266264768 samples / 48 kHz = 5547.18 s, a 0.097% difference we have not explained — so frames/24 is exact in Resolve's source-time space, which is the space every other Resolve call uses, but may sit ~2 s off the byte position in a 40-minute-deep offset. Re-confirmed on Studio 19.1.3.7 (2026-08-10) with synthetic media, so this is not a 21.x regression: a 300 s 48 kHz WAV reports FPS 24, and appending source frames 4800-5235 of it to a 29.97 fps timeline yields a timeline duration of 543 (= 435 x 29.97/24), which is the conversion happening in the open. The same run measured the cost of the derived end: source_end came back 5343 (4800 + 543) where the true source end is 5235, so source_end / 24 reports 222.625 s against a real 218.133 s from GetSourceEndTime — 4.49 s out, on a clip only 18.1 s long. GetSourceStartTime read exactly 200.0 s (= 4800/24) on the same item. The matching VIDEO item (29.97 source in a 29.97 timeline) was unaffected in both: 24.524 s read against 24.525 s derived. Both second-readers exist on 19.1.3.7, so the GetSourceEndFrame fallback below is for builds older still.
390
- - **Workaround / current handling:** Convert an audio item's source frames with the MEDIA's rate, never the timeline's: seconds = source_start / media_fps, treating a WAV (or any container with no native rate) as 24 fps. Take media_fps from the media-pool item's 'FPS' clip property or from ffprobe — do not infer it from the timeline. Feed the frames back to timeline create_variant_from_ranges in the same media-rate space you read them in; it converts on placement and reports the conversion in items[].duration_delta. The separate GetSourceStartFrame entry above (off-by-one vs GetLeftOffset) applies on top of this — the rate question is which unit the number is in, not whether it is exact. Mitigated in-process: _timeline_item_summary now emits source_fps and source_start_seconds/source_end_seconds beside the frames, so the number always arrives with its unit; on the GetLeftOffset fallback for an audio item it reports the rate as unknown rather than converting a timeline-frame value at the media rate. Note which number may be converted: source_end in the same summary is derived as source_start + TIMELINE duration, so on this WAV it adds 435 frames of 29.97 record time to a 24 fps source frame and dividing THAT by 24 reports an 18.125 s span for a 14.515 s clip. The seconds therefore come from GetSourceStartTime/GetSourceEndTime which answer in seconds with no rate inference at all then from GetSourceEndFrame / media_fps, and read null rather than convert the derived source_end.
389
+ - **Behavior:** The value is counted in the source MEDIA's own frame rate, not the timeline's. CORRECTION (2026-08-10, Studio 19.1.3.7): an earlier version of this entry said a WAV 'carries no frame rate, so Resolve falls back to 24 fps'. That is WRONG, and 24 is not a constant to rely on. A WAV takes the PROJECT's timelineFrameRate AT IMPORT and freezes it. Measured with one 400.000 s 48 kHz WAV imported into three project states: project at 24 -> clip FPS 24.0, Duration 00:06:40:00 (9600 frames = 400 s); project at 29.97 -> clip FPS 29.97, Duration 00:06:39:18 (11988 frames = 400 s); and changing the project rate to 29.97 AFTER import left the clip reading 24.0 (SetSetting returned True and the project did move). So the mismatch is not 'audio is always 24' but 'the clip kept the rate the project had when it was imported, and the project moved afterwards' — which also means a WAV imported into a 29.97 project behaves exactly like video, with no trap at all. ALWAYS read the clip's FPS property; never assume 24. The original 21.0.3.7 report below is consistent with this: that project was at 24 when the WAV was imported. Reading the frames at the timeline rate lands minutes away from the real position in the file. Verified live on Studio 21.0.3.7 (2026-08-09, 29.97 fps timeline): a ZOOM0028.WAV item reported source_start 56871, which is 56871 / 24 = 2369.6 s into the file, NOT the 1897.6 s a 29.97 fps reading gives — a 471.9 s (7 min 52 s) error. Nothing looks wrong, because timeline probe_timeline_structure derives source_end as source_start + timeline_duration: the start/end pair stays internally consistent whatever rate you assume. VIDEO items are NOT affected — two 29.97 fps items (KR020007.MOV, IMG_0001.mov) on the same timeline reported source frames in their own, matching rate, confirmed against ffprobe durations and span arithmetic. This is the read-side twin of the AppendToTimeline mixed-fps entry below: that one is about writing source frames whose rate differs from the timeline's, this one about reading them back and not knowing which rate they are in. The rate was pinned by regression, not assumed: across 12 items of the same WAV, GetSourceStartFrame advances at 24.000 fps against the item's own GetSourceStartTime (24.0000/24.0007/23.9995 over spans up to 22 minutes). The same measurement exposed a second unit trap: on an AUDIO item GetLeftOffset advances at 29.970 — the TIMELINE rate — so the two readers describe the same edit point in DIFFERENT frame spaces (60687 vs 75784 for one item). On video they share the source space. Caveat on the absolute zero: Resolve's model of this file is 133003 frames (Duration 01:32:21:19 at 24 fps = 5541.79 s) while its true PCM length is 266264768 samples / 48 kHz = 5547.18 s, a 0.097% difference we have not explained — so frames/24 is exact in Resolve's source-time space, which is the space every other Resolve call uses, but may sit ~2 s off the byte position in a 40-minute-deep offset. Re-confirmed on Studio 19.1.3.7 (2026-08-10) with synthetic media, so this is not a 21.x regression: a 300 s 48 kHz WAV reports FPS 24, and appending source frames 4800-5235 of it to a 29.97 fps timeline yields a timeline duration of 543 (= 435 x 29.97/24), which is the conversion happening in the open. The same run measured the cost of the derived end: source_end came back 5343 (4800 + 543) where the true source end is 5235, so source_end / 24 reports 222.625 s against a real 218.133 s from GetSourceEndTime — 4.49 s out, on a clip only 18.1 s long. GetSourceStartTime read exactly 200.0 s (= 4800/24) on the same item. The matching VIDEO item (29.97 source in a 29.97 timeline) was unaffected in both: 24.524 s read against 24.525 s derived. Both second-readers exist on 19.1.3.7, so the GetSourceEndFrame fallback below is for builds older still. GetSourceEndFrame ITSELF changes convention between the two regimes and cannot be used raw: measured over 12 items on 19.1.3.7, it is EXCLUSIVE (equals the endFrame sent) when the source rate equals the timeline rate, and INCLUSIVE (one less) when they differ — off by one in exactly the case a caller reaches for it. It is not a media-type split: the same WAV imported at 29.97 into a 29.97 timeline read exclusive, like video, and only the rate MISMATCH flipped it. What IS stable across both regimes is GetSourceEndTime x media_fps, which was exact on 12 of 12 valid items (30.633 s x 24 = 735.19 -> 735; 24.524 s x 29.97 = 734.98 -> 735) — seconds carry no frame-rate assumption, so the product is in source space by construction.
390
+ - **Workaround / current handling:** Convert an audio item's source frames with the MEDIA's rate, never the timeline's: seconds = source_start / media_fps. READ media_fps from the media-pool item's 'FPS' clip property (or ffprobe) every time — do not infer it from the timeline, and do NOT hard-code 24 for a WAV: that number is whatever the project rate was when the clip was imported, so it is 24 only for a project that was at 24, and a WAV imported at 29.97 has no mismatch at all. Feed the frames back to timeline create_variant_from_ranges in the same media-rate space you read them in; it converts on placement and reports the conversion in items[].duration_delta. The separate GetSourceStartFrame entry above (off-by-one vs GetLeftOffset) applies on top of this — the rate question is which unit the number is in, not whether it is exact. Mitigated in-process: _timeline_item_summary now emits source_fps and source_start_seconds/source_end_seconds beside the frames, so the number always arrives with its unit; on the GetLeftOffset fallback for an audio item it reports the rate as unknown rather than converting a timeline-frame value at the media rate. source_end is no longer source_start + TIMELINE duration: as of v2.93.0 it is round(GetSourceEndTime x media_fps), which is a SOURCE frame by construction and stays EXCLUSIVE as every caller already assumed. Measured live on 19.1.3.7 over 8 items in both regimes, it equals the endFrame actually sent every time, and it reproduces the old value exactly wherever the old value was already right so matched-rate media does not move. The old arithmetic overshot by +24/+26/+108/+149 frames on the mismatched WAV, and timeline extract_source_frame_ranges built pull ranges out of it: widths of 543 and 749 for clips that consume 435 and 600 source frames. It falls back to the old sum only when GetSourceEndTime or the rate is unreadable.
391
391
  - **Tags:** timeline, audio, wav, frame-rate, mixed-fps, silent-failure, readback
392
392
 
393
393
  ### Studio-gated calls on the free edition raise a modal that blocks LATER calls
package/install.py CHANGED
@@ -36,7 +36,7 @@ from src.utils.update_check import (
36
36
 
37
37
  # ─── Version ──────────────────────────────────────────────────────────────────
38
38
 
39
- VERSION = "2.91.0"
39
+ VERSION = "2.93.0"
40
40
  # Only hard floor: mcp[cli] requires Python 3.10+. There is no upper bound —
41
41
  # Resolve's scripting bridge loads into newer interpreters on recent builds
42
42
  # (Python 3.14 verified against Resolve Studio 20.3.2). Older Resolve builds
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "davinci-resolve-mcp",
3
- "version": "2.91.0",
3
+ "version": "2.93.0",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -223,22 +223,69 @@ def stale_container_warning(targets: list[Path]) -> str | None:
223
223
  )
224
224
 
225
225
 
226
- #: Resolve enumerates `.py` scripts in Workspace > Scripts only when it finds a
227
- #: **framework** Python. Homebrew/pyenv/conda interpreters are not detected, and
228
- #: the failure is completely silent: the script sits in the right folder with the
229
- #: right permissions and simply never appears. Lua is embedded, so `.lua` always
230
- #: lists — which is why a Lua canary is installed alongside the probe.
226
+ #: Resolve enumerates `.py` scripts in Workspace > Scripts only when it can find
227
+ #: and load a Python 3, and the failure is completely silent: the script sits in
228
+ #: the right folder with the right permissions and simply never appears. Lua is
229
+ #: embedded, so `.lua` always lists which is why a Lua canary is installed
230
+ #: alongside the probe.
231
+ #:
232
+ #: **What it actually looks for (issue #143, corrected 2026-08-10).** This check
233
+ #: used to require a *framework* build and send everyone else to python.org. That
234
+ #: was the right remedy for the wrong reason, and it turned a one-line fix into a
235
+ #: system-wide `sudo` install that managed machines often forbid. In
236
+ #: `fusionscript.so` — read on Studio 19.1.3.7, a January 2025 binary, matching
237
+ #: what the reporter read on 21.0.4.5 — every `Python.framework` reference is
238
+ #: Python **2.7**:
239
+ #:
240
+ #: /Library/Frameworks/Python.framework/Versions/2.7/
241
+ #: /System/Library/Frameworks/Python.framework/Versions/2.7/
242
+ #:
243
+ #: There is no `Python.framework/Versions/3` string at all. Python 3 is found by
244
+ #: a different mechanism entirely, whose pieces sit adjacent in the binary:
245
+ #:
246
+ #: PYTHON3HOME
247
+ #: /usr/local/bin/python3
248
+ #: python3 -c 'import sys; sys.stdout.write("%s.%s|%s" % (sys.version_info.major, sys.version_info.minor, sys.prefix))'
249
+ #: /libpython
250
+ #:
251
+ #: i.e. resolve `PYTHON3HOME`, else `/usr/local/bin/python3`; probe it for its
252
+ #: version and `sys.prefix`; `dlopen` `<prefix>/lib/libpython3.X.dylib`. The
253
+ #: ordering is inferred from adjacency rather than decompiled control flow, but
254
+ #: the two entry points are not in doubt.
255
+ #:
256
+ #: That explains every observation the old rule was built on. python.org works
257
+ #: because its installer creates `/usr/local/bin/python3` — verified here, where
258
+ #: it is a symlink into `Python.framework/Versions/3.11`. Homebrew on Apple
259
+ #: silicon installs to `/opt/homebrew/bin`, pyenv to `~/.pyenv/shims`, and uv to
260
+ #: `~/.local/share/uv/...`; none of them land `/usr/local/bin/python3`, so none
261
+ #: are found. Framework-ness was never the variable — it was correlated with it.
262
+ #:
263
+ #: So a non-framework interpreter works once Resolve can see it, and `uv`/`pixi`/
264
+ #: conda-forge users are not stuck: they can point `PYTHON3HOME` at their prefix
265
+ #: without touching a system directory. It must be set with `launchctl setenv`,
266
+ #: not `export` — Resolve is GUI-launched and inherits launchd's environment, not
267
+ #: any shell's.
231
268
  _FRAMEWORK_PYTHON_ROOTS = (
232
269
  Path("/Library/Frameworks/Python.framework/Versions"),
233
270
  Path("/System/Library/Frameworks/Python.framework/Versions"),
234
271
  )
235
272
 
273
+ #: The interpreter path baked into fusionscript.so as the fallback when
274
+ #: PYTHON3HOME is unset. This is the one python.org's installer creates.
275
+ _FALLBACK_PYTHON3 = Path("/usr/local/bin/python3")
276
+
236
277
  _LUA_CANARY = """-- Installed by davinci-resolve-mcp as an enumeration canary.
237
278
  -- If THIS appears under Workspace > Scripts but resolve_bridge_probe does not,
238
- -- Resolve is listing Lua and silently skipping Python: it cannot find a
239
- -- framework Python install. Install one from python.org and restart Resolve.
240
- print("Resolve is enumerating scripts. If the Python probe is missing, install a")
241
- print("framework Python from python.org (Homebrew/pyenv are NOT detected).")
279
+ -- Resolve is listing Lua and silently skipping Python: it cannot find a Python 3.
280
+ -- It looks at PYTHON3HOME, then /usr/local/bin/python3 -- and nowhere else, which
281
+ -- is why Homebrew, pyenv, uv and conda interpreters go unseen. Either point it at
282
+ -- the one you have (no sudo):
283
+ -- launchctl setenv PYTHON3HOME "$(python3 -c 'import sys; print(sys.prefix)')"
284
+ -- (launchctl, not export -- Resolve never sees your shell), or install a
285
+ -- python.org build, which creates /usr/local/bin/python3. Restart Resolve after.
286
+ print("Resolve is enumerating scripts. If the Python probe is missing, Resolve")
287
+ print("cannot find a Python 3: set PYTHON3HOME with launchctl setenv, or install")
288
+ print("a python.org build. Homebrew/pyenv/uv/conda are not looked at directly.")
242
289
  """
243
290
 
244
291
 
@@ -258,37 +305,145 @@ def framework_pythons() -> list[str]:
258
305
  return found
259
306
 
260
307
 
308
+ def launchd_env(name: str) -> str | None:
309
+ """A variable's value as a GUI-launched Resolve sees it.
310
+
311
+ Deliberately NOT ``os.environ``. Resolve is started from the Dock or Finder,
312
+ so it inherits launchd's environment; a ``export PYTHON3HOME=...`` in the
313
+ terminal running this installer is invisible to it. Reading our own
314
+ environment would report a hit in precisely the case where the user has done
315
+ the wrong thing and needs to be told so.
316
+ """
317
+ import subprocess
318
+
319
+ try:
320
+ completed = subprocess.run(
321
+ ["launchctl", "getenv", name],
322
+ capture_output=True, text=True, timeout=10, check=False,
323
+ )
324
+ except (OSError, ValueError, subprocess.SubprocessError):
325
+ return None
326
+ value = (completed.stdout or "").strip()
327
+ return value or None
328
+
329
+
330
+ def python3_home_prefix() -> dict:
331
+ """Is PYTHON3HOME set for Resolve, and does it point at a loadable Python 3?
332
+
333
+ "Loadable" means the `lib/libpython3.X.dylib` that fusionscript.so dlopens.
334
+ An interpreter that cannot supply one is reported as set-but-unusable rather
335
+ than counted, because the silent-non-enumeration symptom is identical and the
336
+ remedy is not.
337
+ """
338
+ value = launchd_env("PYTHON3HOME")
339
+ result = {
340
+ "value": value,
341
+ "in_launchd": value is not None,
342
+ "in_this_shell": os.environ.get("PYTHON3HOME") or None,
343
+ "dylib": None,
344
+ "usable": False,
345
+ }
346
+ if not value:
347
+ return result
348
+ try:
349
+ dylibs = sorted(Path(value).glob("lib/libpython3.*.dylib"))
350
+ except OSError:
351
+ dylibs = []
352
+ if dylibs:
353
+ result["dylib"] = str(dylibs[0])
354
+ result["usable"] = True
355
+ return result
356
+
357
+
358
+ def fallback_python3() -> dict:
359
+ """`/usr/local/bin/python3` — the path baked into fusionscript.so.
360
+
361
+ Present for a python.org install (its installer creates it) and absent for
362
+ Homebrew, pyenv, uv and conda, which is the whole of the "framework Pythons
363
+ only" folklore.
364
+ """
365
+ result = {"path": str(_FALLBACK_PYTHON3), "exists": False, "resolves_to": None}
366
+ try:
367
+ if not _FALLBACK_PYTHON3.exists():
368
+ return result
369
+ result["exists"] = True
370
+ result["resolves_to"] = str(_FALLBACK_PYTHON3.resolve())
371
+ except OSError:
372
+ pass
373
+ return result
374
+
375
+
261
376
  def python_preflight() -> dict:
262
377
  """Will Resolve list the Python probe we are about to install?
263
378
 
264
- The framework-Python trap is **macOS-only**: `/Library/Frameworks/...` does
265
- not exist on Windows or Linux, where Resolve finds Python by other means (the
266
- registry, or the system interpreter). Running the macOS check there always
267
- found nothing and emitted the macOS remediation — telling the Windows 11 user
268
- in issue #106, who had a working python.org 3.12, to go install the Python
269
- they already had. Report "no known reason it will not list" off macOS rather
270
- than a false alarm; the Lua canary still ships either way, so a genuine
271
- enumeration problem remains diagnosable.
379
+ The trap is **macOS-only**: `/Library/Frameworks/...` and
380
+ `/usr/local/bin/python3` are not how Resolve finds Python on Windows or
381
+ Linux (the registry, or the system interpreter). Running the macOS check
382
+ there always found nothing and emitted the macOS remediation — telling the
383
+ Windows 11 user in issue #106, who had a working python.org 3.12, to go
384
+ install the Python they already had. Report "no known reason it will not
385
+ list" off macOS rather than a false alarm; the Lua canary still ships either
386
+ way, so a genuine enumeration problem remains diagnosable.
387
+
388
+ On macOS, Resolve is satisfied by EITHER discovery route (see the note on
389
+ `_FRAMEWORK_PYTHON_ROOTS`), so requiring a framework build failed a `uv` or
390
+ `pixi` user who had a perfectly good interpreter and sent them to a
391
+ system-wide `sudo` install they may not be permitted to run — issue #143,
392
+ where free Resolve 21.0.4.5 ran the bridge on uv-managed CPython 3.12.13
393
+ with no python.org Python on the machine at all.
272
394
  """
273
395
  if sys.platform != "darwin":
274
396
  return {
275
397
  "framework_pythons": [],
398
+ "python3_home": None,
399
+ "fallback_python3": None,
276
400
  "resolve_will_list_python_scripts": True,
277
401
  "advice": None,
278
402
  }
279
403
  frameworks = framework_pythons()
404
+ home = python3_home_prefix()
405
+ fallback = fallback_python3()
406
+ # Either route is sufficient. PYTHON3HOME wins when both are present: that is
407
+ # the order the binary's strings imply, and it is the one the user chose.
408
+ found = home["usable"] or fallback["exists"] or bool(frameworks)
409
+ advice = None
410
+ if not found:
411
+ advice = (
412
+ "Resolve cannot find a Python 3, so it will silently ignore every "
413
+ ".py script in its Scripts folders — they will simply not appear in "
414
+ "Workspace > Scripts, with no error. It looks in exactly two places: "
415
+ "the PYTHON3HOME environment variable, then /usr/local/bin/python3. "
416
+ "Homebrew (/opt/homebrew/bin), pyenv, uv and conda land in neither, "
417
+ "which is why they appear 'unsupported'.\n"
418
+ "Fix it either way:\n"
419
+ " 1. Point Resolve at the interpreter you already have, no sudo:\n"
420
+ " launchctl setenv PYTHON3HOME \"$(python3 -c 'import sys; "
421
+ "print(sys.prefix)')\"\n"
422
+ " Use launchctl, NOT export — Resolve is launched from the Dock "
423
+ "and never sees your shell's environment. The prefix must contain "
424
+ "lib/libpython3.X.dylib.\n"
425
+ " 2. Or install a python.org build, which creates "
426
+ "/usr/local/bin/python3 for you.\n"
427
+ "Restart Resolve either way, then re-check. The Lua canary installed "
428
+ "alongside will list regardless, so you can tell 'Python not "
429
+ "detected' apart from 'wrong folder'."
430
+ )
431
+ elif home["in_this_shell"] and not home["in_launchd"]:
432
+ # Found by another route, but the user has clearly tried this one and it
433
+ # will not survive into Resolve. Say so before they conclude it worked.
434
+ advice = (
435
+ "PYTHON3HOME is set in this shell but not in launchd, so Resolve "
436
+ "will not see it — Resolve is GUI-launched and inherits launchd's "
437
+ "environment, not your shell's. Python will still be found via "
438
+ "another route this time. To make PYTHON3HOME the one that counts: "
439
+ "launchctl setenv PYTHON3HOME \"$PYTHON3HOME\", then restart Resolve."
440
+ )
280
441
  return {
281
442
  "framework_pythons": frameworks,
282
- "resolve_will_list_python_scripts": bool(frameworks),
283
- "advice": None if frameworks else (
284
- "No framework Python found. Resolve will silently ignore every .py "
285
- "script in its Scripts folders — they will simply not appear in "
286
- "Workspace > Scripts, with no error. Homebrew, pyenv and conda "
287
- "interpreters are NOT detected. Install a framework build from "
288
- "python.org (any recent 3.x), restart Resolve, and re-check. The "
289
- "Lua canary installed alongside will list either way, so you can "
290
- "tell 'Python not detected' apart from 'wrong folder'."
291
- ),
443
+ "python3_home": home,
444
+ "fallback_python3": fallback,
445
+ "resolve_will_list_python_scripts": found,
446
+ "advice": advice,
292
447
  }
293
448
 
294
449
 
@@ -87,7 +87,7 @@ if not logging.getLogger().handlers:
87
87
  handlers=[logging.StreamHandler()],
88
88
  )
89
89
 
90
- VERSION = "2.91.0"
90
+ VERSION = "2.93.0"
91
91
  logger = logging.getLogger("davinci-resolve-mcp")
92
92
  logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
93
93
  logger.info(f"Detected platform: {get_platform()}")
package/src/server.py CHANGED
@@ -11,7 +11,7 @@ Usage:
11
11
  python src/server.py --full # Start the 353-tool granular server instead
12
12
  """
13
13
 
14
- VERSION = "2.91.0"
14
+ VERSION = "2.93.0"
15
15
 
16
16
  import base64
17
17
  import os
@@ -980,8 +980,10 @@ def _not_connected_error():
980
980
  reason="DAVINCI_RESOLVE_BRIDGE is set, so no other transport is tried.",
981
981
  remediation="In Resolve, run Workspace > Scripts > resolve_bridge. If it is not in "
982
982
  "that menu, run `python scripts/install_resolve_bridge.py` and restart "
983
- "Resolve; a framework Python from python.org is required for Resolve to "
984
- "list .py scripts at all.",
983
+ "Resolve. On macOS, Resolve lists .py scripts only if it can find a "
984
+ "Python 3 via PYTHON3HOME or /usr/local/bin/python3; if neither exists, "
985
+ "run `launchctl setenv PYTHON3HOME \"$(python3 -c 'import sys; "
986
+ "print(sys.prefix)')\"` (launchctl, not export) and restart Resolve.",
985
987
  state={"resolve_running": running, "bridge_enabled": True},
986
988
  )
987
989
  if running:
@@ -3056,12 +3058,16 @@ def _media_item_source_fps(media_pool_item, clip_properties=None):
3056
3058
  """The frame rate a media-pool item's SOURCE frames are counted in.
3057
3059
 
3058
3060
  Source frames (GetSourceStartFrame / GetLeftOffset) are expressed in the
3059
- MEDIA's own rate, never the timeline's and a WAV carries no native rate,
3060
- so Resolve reports 24 for it. Read the rate instead of assuming the
3061
- timeline's: at 29.97 a WAV offset lands minutes away from the real position
3062
- in the file, and nothing errors (see the api_truth entry "GetSourceStartFrame
3063
- on an AUDIO item"). Returns None when the rate cannot be read, so callers
3064
- surface "unknown" rather than a guess.
3061
+ MEDIA's own rate, never the timeline's. A WAV has no intrinsic rate, so it
3062
+ takes the PROJECT's timelineFrameRate at IMPORT and freezes it measured on
3063
+ Studio 19.1.3.7: imported at 24 it reads 24.0, imported at 29.97 it reads
3064
+ 29.97, and moving the project afterwards does not change it. So the rate is
3065
+ read here every time and never assumed; 24 in particular is not a WAV
3066
+ constant, only the value a project that was at 24 handed its imports. Reading
3067
+ a mismatched offset at the timeline rate lands minutes from the real position
3068
+ in the file and nothing errors (see the api_truth entry
3069
+ "GetSourceStartFrame on an AUDIO item"). Returns None when the rate cannot be
3070
+ read, so callers surface "unknown" rather than a guess.
3065
3071
 
3066
3072
  Pass ``clip_properties`` when the caller already holds the item's property
3067
3073
  dict — the probe path does, so this costs it no extra bridge call.
@@ -3112,13 +3118,16 @@ def _timeline_item_source_time(item, method):
3112
3118
 
3113
3119
 
3114
3120
  def _timeline_item_source_end_frame(item):
3115
- """The item's SOURCE-space end frame, from Resolve's own reader.
3116
-
3117
- Distinct from the ``source_end`` this module derives as
3118
- ``source_start + duration``: that duration is a TIMELINE duration
3119
- (GetDuration), so the sum mixes units the moment the media rate differs
3120
- from the timeline's always, for a WAV. Only a genuine source-space frame
3121
- may be divided by source_fps.
3121
+ """`GetSourceEndFrame` raw WARNING: its end convention is not fixed.
3122
+
3123
+ Measured on Studio 19.1.3.7 (2026-08-10, 12 items, both regimes): this reader
3124
+ is **exclusive** when the source rate equals the timeline rate, and
3125
+ **inclusive** when they differ off by one in exactly the case a caller
3126
+ reaches for it. It is not a media-type split; a WAV imported at 29.97 into a
3127
+ 29.97 timeline reads exclusive like video, and only the rate MISMATCH flips
3128
+ it. Prefer `_timeline_item_source_end_exclusive`, which sidesteps the
3129
+ convention entirely. Kept raw for the seconds fallback, where a one-frame
3130
+ difference is below the reported precision.
3122
3131
  """
3123
3132
  if not _has_method(item, "GetSourceEndFrame"):
3124
3133
  return None
@@ -3128,6 +3137,45 @@ def _timeline_item_source_end_frame(item):
3128
3137
  return None
3129
3138
 
3130
3139
 
3140
+ def _timeline_item_source_end_exclusive(item, source_start, duration, source_fps):
3141
+ """The EXCLUSIVE source-space end frame — the contract every caller assumes.
3142
+
3143
+ `source_end` has always been exclusive (`src_end_excl` at the append site,
3144
+ `source_end - 1` wherever an inclusive end is wanted). What was wrong was the
3145
+ arithmetic: `source_start + duration` adds a TIMELINE duration to a SOURCE
3146
+ frame, so it is unit-mixed the moment the two rates differ. Measured on
3147
+ Studio 19.1.3.7 against the endFrame actually sent, it overshot by +24, +26,
3148
+ +108 and +149 frames on a 24 fps WAV in a 29.97 timeline, while being exact
3149
+ on every matched-rate item.
3150
+
3151
+ Resolve's own second-reader settles it without knowing the timeline rate:
3152
+ seconds carry no frame-rate assumption, so `GetSourceEndTime x source_fps` is
3153
+ in source space by construction. Measured exact on **12 of 12** valid items
3154
+ across both regimes and both media types — 30.633 s x 24 = 735.19 -> 735,
3155
+ 24.524 s x 29.97 = 734.98 -> 735 — and it reproduces the derived value
3156
+ wherever the derived value was already right, so nothing moves for
3157
+ matched-rate media.
3158
+
3159
+ Deliberately NOT `GetSourceEndFrame`: that reader changes convention between
3160
+ the two regimes (see above), so building on it would mean branching on a
3161
+ rate comparison this function would first have to reconstruct.
3162
+
3163
+ Falls back to the historical `source_start + duration` when the second-reader
3164
+ or the rate is unreadable — same value as before, so an older build loses the
3165
+ correction rather than the field.
3166
+ """
3167
+ end_seconds = _timeline_item_source_time(item, "GetSourceEndTime")
3168
+ if end_seconds is not None and source_fps:
3169
+ end_frame = int(round(end_seconds * source_fps))
3170
+ # A source end at or before the start means the readers disagree about
3171
+ # this item; the derived value is the safer answer than a negative span.
3172
+ if source_start is None or end_frame > source_start:
3173
+ return end_frame
3174
+ if source_start is not None and duration is not None:
3175
+ return source_start + duration
3176
+ return None
3177
+
3178
+
3131
3179
  def _timeline_item_source_seconds(item, source_start, source_end, source_fps):
3132
3180
  """(start_seconds, end_seconds) into the source file, or None each.
3133
3181
 
@@ -3159,15 +3207,21 @@ def _timeline_item_summary(item, track_info=None, *, media_pool_item=None,
3159
3207
  pass
3160
3208
  duration = _timeline_item_duration(item, start, end)
3161
3209
  source_start, source_start_origin = _timeline_item_source_start_with_origin(item)
3162
- if source_start is not None and duration is not None:
3163
- source_end = source_start + duration
3164
3210
  if track_info is None:
3165
3211
  track_info, _ = _timeline_item_track_info(item)
3166
3212
  if media_pool_item is None:
3167
3213
  media_pool_item = _timeline_item_media_pool_item(item)
3168
- # source_* are in the MEDIA's frame rate; report it and the derived seconds
3169
- # so a caller never has to guess which rate the frame numbers are in.
3170
- source_fps = _media_item_source_fps(media_pool_item, clip_properties)
3214
+ # source_* are in the MEDIA's frame rate; report it and the seconds beside
3215
+ # them so a caller never has to guess which rate the frame numbers are in.
3216
+ media_fps = _media_item_source_fps(media_pool_item, clip_properties)
3217
+ # EXCLUSIVE, as it has always been — but computed in source space now, not
3218
+ # by adding a timeline duration to a source frame. media_fps rather than
3219
+ # source_fps below: the end comes from GetSourceEndTime, which is independent
3220
+ # of whichever reader produced source_start, so the audio caveat that blanks
3221
+ # source_fps must not blank the end as well.
3222
+ source_end = _timeline_item_source_end_exclusive(
3223
+ item, source_start, duration, media_fps)
3224
+ source_fps = media_fps
3171
3225
  if source_start_origin == "GetLeftOffset" and (track_info or (None,))[0] == "audio":
3172
3226
  # GetLeftOffset counts an audio item in TIMELINE frames, so pairing it
3173
3227
  # with the media rate would produce a confidently wrong number. Report
@@ -3184,9 +3238,9 @@ def _timeline_item_summary(item, track_info=None, *, media_pool_item=None,
3184
3238
  "end": end,
3185
3239
  "duration": duration,
3186
3240
  "source_start": source_start,
3187
- # NOTE: derived as source_start + TIMELINE duration, so it is unit-mixed
3188
- # whenever source_fps != the timeline rate. Kept for compatibility; use
3189
- # source_end_seconds, which is read from source space, for real time.
3241
+ # EXCLUSIVE source frame. Read from source space via GetSourceEndTime;
3242
+ # falls back to source_start + TIMELINE duration only when that reader or
3243
+ # the media rate is unavailable, which is the old unit-mixed value.
3190
3244
  "source_end": source_end,
3191
3245
  "source_fps": source_fps,
3192
3246
  "source_start_seconds": source_start_seconds,
@@ -21533,8 +21587,10 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
21533
21587
  create_variant_from_ranges takes SOURCE start_frame/end_frame; extract_source_frame_ranges
21534
21588
  and source_range_report return SOURCE ranges. A SOURCE frame is counted in the MEDIA's own
21535
21589
  frame rate, not the timeline's: an AUDIO item's source_start/source_end read back in the
21536
- file's rate, and a WAV (no native rate) defaults to 24 fps, so converting one at the timeline
21537
- rate is silently wrong by minutes (resolve_control api_truth "GetSourceStartFrame").
21590
+ file's rate. A WAV has no intrinsic rate and freezes the PROJECT's rate at import, so it
21591
+ differs from the timeline whenever the project moved afterwards — read source_fps, never
21592
+ assume 24, and converting at the timeline rate is silently wrong by minutes
21593
+ (resolve_control api_truth "GetSourceStartFrame").
21538
21594
 
21539
21595
  Actions:
21540
21596
  list() -> {timelines}
@@ -21654,14 +21710,16 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
21654
21710
  Default handles=24, gap_max=30. Use handles=0 for gap-only auto handles.
21655
21711
  conform_capabilities() -> {supported, partially_supported, unsupported, export_aliases}
21656
21712
  probe_timeline_structure(track_types?, include_markers?, include_clip_properties?) -> {tracks, markers}
21657
- Each item reports source_start in the MEDIA's frame rate, the source_fps it is
21658
- counted in, and source_start_seconds/source_end_seconds. Use those seconds a
21659
- WAV counts at 24 fps on any timeline, so dividing by the timeline rate is wrong
21660
- by minutes. source_fps is null when the rate could not be read; treat the frames
21661
- as unitless then, do not assume the timeline's. source_end is derived as
21662
- source_start + TIMELINE duration, so it is unit-mixed when the rates differ —
21663
- the seconds come from Resolve's own source-time readers instead, and read null
21664
- rather than convert the derived value.
21713
+ Each item reports source_start/source_end (SOURCE frames, end EXCLUSIVE) in the
21714
+ MEDIA's frame rate, the source_fps they are counted in, and
21715
+ source_start_seconds/source_end_seconds. Use the reported source_fps a WAV
21716
+ freezes the PROJECT's rate at import, so it differs from the timeline whenever
21717
+ the project moved afterwards, and dividing by the timeline rate is then wrong by
21718
+ minutes. source_fps is null when the rate could not be read; treat the frames as
21719
+ unitless then, do not assume the timeline's. source_end comes from
21720
+ GetSourceEndTime x source_fps, so it is a source frame even when the rates
21721
+ differ; it falls back to source_start + TIMELINE duration (unit-mixed) only when
21722
+ that reader or the rate is unavailable.
21665
21723
  detect_gaps_overlaps(track_types?, min_gap?) -> {gaps, overlaps}
21666
21724
  source_range_report(handles?, merge?) -> {ranges, occurrences}
21667
21725
  export_timeline_checked(path, format?|type?, subtype?, require_temp_path?, dry_run?, background?) -> {success, path, size | job_id}
@@ -23491,10 +23549,10 @@ _ACTION_HELP: Dict[str, Dict[str, Dict[str, Any]]] = {
23491
23549
  "track_index is the 1-based destination track WITHIN track_type (default 1); the "
23492
23550
  "variant is created with enough video/audio tracks to cover the highest index used, "
23493
23551
  "so multicam angles can be rebuilt onto V2/V3 instead of collapsing onto V1. "
23494
- "SOURCE frames are counted in the MEDIA's frame rate, not the timeline's — an audio "
23495
- "item's read back as 24 fps for a WAV (api_truth \"GetSourceStartFrame on an AUDIO "
23496
- "item\"); pass them in that space, placement converts and items[].duration_delta "
23497
- "reports the conversion. "
23552
+ "SOURCE frames are counted in the MEDIA's frame rate, not the timeline's — read the "
23553
+ "clip's source_fps rather than assuming one, since a WAV freezes the PROJECT's rate "
23554
+ "at import (api_truth \"GetSourceStartFrame on an AUDIO item\"); pass the frames in "
23555
+ "that space, placement converts and items[].duration_delta reports the conversion. "
23498
23556
  "pack=true butts clips together at the end of each track (gap-free, ignores record_frame)"
23499
23557
  ),
23500
23558
  "returns": "{success, id, items} — items[].placed = placed frames; items[].range = the requested range",
@@ -23506,7 +23564,7 @@ _ACTION_HELP: Dict[str, Dict[str, Dict[str, Any]]] = {
23506
23564
  ' {"clip_id": "<cam3-id>", "start_frame": 1500, "end_frame": 1600,\n'
23507
23565
  ' "track_index": 2},\n'
23508
23566
  ' {"clip_id": "<wav-id>", "track_type": "audio", "track_index": 1,\n'
23509
- ' "start_frame": 56871, "end_frame": 57591} # 24 fps source frames\n'
23567
+ ' "start_frame": 56871, "end_frame": 57591} # frames in the WAV\'s own source_fps\n'
23510
23568
  ' ],\n'
23511
23569
  ' "dry_run": True\n'
23512
23570
  '})'
@@ -661,12 +661,28 @@ API_TRUTH: List[Dict[str, Any]] = [
661
661
  "submit": "bug",
662
662
  },
663
663
  {
664
- "symbol": "TimelineItem.GetSourceStartFrame on an AUDIO item (media-rate frames; 24 fps for WAV)",
664
+ "symbol": "TimelineItem.GetSourceStartFrame on an AUDIO item (media-rate frames; a WAV freezes the PROJECT rate at import)",
665
665
  "object": "TimelineItem",
666
666
  "signature": "() -> int # source frame, counted in the MEDIA's frame rate",
667
667
  "reality": "The value is counted in the source MEDIA's own frame rate, not "
668
- "the timeline's and a WAV carries no frame rate, so Resolve "
669
- "falls back to 24 fps. Reading it at the timeline rate lands "
668
+ "the timeline's. CORRECTION (2026-08-10, Studio 19.1.3.7): an "
669
+ "earlier version of this entry said a WAV 'carries no frame rate, "
670
+ "so Resolve falls back to 24 fps'. That is WRONG, and 24 is not a "
671
+ "constant to rely on. A WAV takes the PROJECT's timelineFrameRate "
672
+ "AT IMPORT and freezes it. Measured with one 400.000 s 48 kHz WAV "
673
+ "imported into three project states: project at 24 -> clip FPS "
674
+ "24.0, Duration 00:06:40:00 (9600 frames = 400 s); project at "
675
+ "29.97 -> clip FPS 29.97, Duration 00:06:39:18 (11988 frames = "
676
+ "400 s); and changing the project rate to 29.97 AFTER import left "
677
+ "the clip reading 24.0 (SetSetting returned True and the project "
678
+ "did move). So the mismatch is not 'audio is always 24' but "
679
+ "'the clip kept the rate the project had when it was imported, "
680
+ "and the project moved afterwards' — which also means a WAV "
681
+ "imported into a 29.97 project behaves exactly like video, with "
682
+ "no trap at all. ALWAYS read the clip's FPS property; never "
683
+ "assume 24. The original 21.0.3.7 report below is consistent with "
684
+ "this: that project was at 24 when the WAV was imported. "
685
+ "Reading the frames at the timeline rate lands "
670
686
  "minutes away from the real position in the file. Verified live "
671
687
  "on Studio 21.0.3.7 (2026-08-09, 29.97 fps timeline): a "
672
688
  "ZOOM0028.WAV item reported source_start 56871, which is "
@@ -712,13 +728,28 @@ API_TRUTH: List[Dict[str, Any]] = [
712
728
  "29.97 timeline) was unaffected in both: 24.524 s read against "
713
729
  "24.525 s derived. Both second-readers exist on 19.1.3.7, so "
714
730
  "the GetSourceEndFrame fallback below is for builds older "
715
- "still.",
731
+ "still. GetSourceEndFrame ITSELF changes convention between "
732
+ "the two regimes and cannot be used raw: measured over 12 "
733
+ "items on 19.1.3.7, it is EXCLUSIVE (equals the endFrame "
734
+ "sent) when the source rate equals the timeline rate, and "
735
+ "INCLUSIVE (one less) when they differ — off by one in "
736
+ "exactly the case a caller reaches for it. It is not a "
737
+ "media-type split: the same WAV imported at 29.97 into a "
738
+ "29.97 timeline read exclusive, like video, and only the "
739
+ "rate MISMATCH flipped it. What IS stable across both "
740
+ "regimes is GetSourceEndTime x media_fps, which was exact "
741
+ "on 12 of 12 valid items (30.633 s x 24 = 735.19 -> 735; "
742
+ "24.524 s x 29.97 = 734.98 -> 735) — seconds carry no "
743
+ "frame-rate assumption, so the product is in source space "
744
+ "by construction.",
716
745
  "recommended": "Convert an audio item's source frames with the MEDIA's rate, "
717
- "never the timeline's: seconds = source_start / media_fps, "
718
- "treating a WAV (or any container with no native rate) as 24 "
719
- "fps. Take media_fps from the media-pool item's 'FPS' clip "
720
- "property or from ffprobe do not infer it from the "
721
- "timeline. Feed the frames back to timeline "
746
+ "never the timeline's: seconds = source_start / media_fps. "
747
+ "READ media_fps from the media-pool item's 'FPS' clip property "
748
+ "(or ffprobe) every time do not infer it from the timeline, "
749
+ "and do NOT hard-code 24 for a WAV: that number is whatever "
750
+ "the project rate was when the clip was imported, so it is 24 "
751
+ "only for a project that was at 24, and a WAV imported at "
752
+ "29.97 has no mismatch at all. Feed the frames back to timeline "
722
753
  "create_variant_from_ranges in the same media-rate space you "
723
754
  "read them in; it converts on placement and reports the "
724
755
  "conversion in items[].duration_delta. The separate "
@@ -730,21 +761,27 @@ API_TRUTH: List[Dict[str, Any]] = [
730
761
  "beside the frames, so the number always arrives with its "
731
762
  "unit; on the GetLeftOffset fallback for an audio item it "
732
763
  "reports the rate as unknown rather than converting a "
733
- "timeline-frame value at the media rate. Note which "
734
- "number may be converted: source_end in the same summary "
735
- "is derived as source_start + TIMELINE duration, so on "
736
- "this WAV it adds 435 frames of 29.97 record time to a 24 "
737
- "fps source frame and dividing THAT by 24 reports an "
738
- "18.125 s span for a 14.515 s clip. The seconds therefore "
739
- "come from GetSourceStartTime/GetSourceEndTime which "
740
- "answer in seconds with no rate inference at all — then "
741
- "from GetSourceEndFrame / media_fps, and read null rather "
742
- "than convert the derived source_end.",
764
+ "timeline-frame value at the media rate. source_end is "
765
+ "no longer source_start + TIMELINE duration: as of "
766
+ "v2.93.0 it is round(GetSourceEndTime x media_fps), "
767
+ "which is a SOURCE frame by construction and stays "
768
+ "EXCLUSIVE as every caller already assumed. Measured "
769
+ "live on 19.1.3.7 over 8 items in both regimes, it "
770
+ "equals the endFrame actually sent every time, and it "
771
+ "reproduces the old value exactly wherever the old value "
772
+ "was already right so matched-rate media does not "
773
+ "move. The old arithmetic overshot by +24/+26/+108/+149 "
774
+ "frames on the mismatched WAV, and timeline "
775
+ "extract_source_frame_ranges built pull ranges out of "
776
+ "it: widths of 543 and 749 for clips that consume 435 "
777
+ "and 600 source frames. It falls back to the old sum "
778
+ "only when GetSourceEndTime or the rate is unreadable.",
743
779
  "tags": ["timeline", "audio", "wav", "frame-rate", "mixed-fps",
744
780
  "silent-failure", "readback"],
745
781
  "submit": "bug",
746
782
  "mitigation": ["_media_item_source_fps", "_source_frames_to_seconds",
747
- "_timeline_item_source_seconds"],
783
+ "_timeline_item_source_seconds",
784
+ "_timeline_item_source_end_exclusive"],
748
785
  },
749
786
  {
750
787
  "symbol": "Razor / blade / split a timeline item",