davinci-resolve-mcp 2.90.0 → 2.92.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 +119 -0
- package/README.md +17 -7
- package/README.zh-CN.md +10 -4
- package/docs/SKILL.md +14 -7
- package/docs/reference/api-limitations.md +9 -1
- package/install.py +1 -1
- package/package.json +1 -1
- package/scripts/install_resolve_bridge.py +182 -27
- package/src/granular/common.py +1 -1
- package/src/server.py +187 -24
- package/src/utils/api_truth.py +86 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,125 @@
|
|
|
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.92.0
|
|
6
|
+
|
|
7
|
+
Two corrections to advice this project was giving confidently and wrongly, both
|
|
8
|
+
from issues filed by users who hit them.
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- **`PYTHON3HOME` satisfies the macOS bridge preflight — uv/pixi/conda need no
|
|
13
|
+
`sudo`.** The preflight demanded a *framework* Python and sent everyone else
|
|
14
|
+
to a system-wide python.org install, which managed machines often forbid.
|
|
15
|
+
`uv`, `pixi` and conda-forge ship no `--enable-framework` build at all, so
|
|
16
|
+
their users got the warning no matter what they did. Reported by @rusanivsky
|
|
17
|
+
in #143, who had free Resolve 21.0.4.5 serving the bridge on uv-managed
|
|
18
|
+
CPython 3.12.13 with no python.org Python on the machine.
|
|
19
|
+
|
|
20
|
+
Framework-ness was never the variable. In `fusionscript.so` — read here on
|
|
21
|
+
Studio 19.1.3.7, a January 2025 binary — every `Python.framework` reference is
|
|
22
|
+
Python **2.7**, and there is no `Python.framework/Versions/3` string anywhere.
|
|
23
|
+
Python 3 is found through `PYTHON3HOME`, else `/usr/local/bin/python3`, then
|
|
24
|
+
probed for `sys.prefix` and dlopened as `<prefix>/lib/libpython3.X.dylib`.
|
|
25
|
+
python.org installs work because that installer creates
|
|
26
|
+
`/usr/local/bin/python3` — verified here, where it is a symlink into
|
|
27
|
+
`Python.framework/Versions/3.11`. Homebrew (`/opt/homebrew`), pyenv, uv and
|
|
28
|
+
conda land in neither place, which is the whole of the "framework Pythons
|
|
29
|
+
only" folklore.
|
|
30
|
+
|
|
31
|
+
The preflight now accepts either route and reports both. `PYTHON3HOME` is read
|
|
32
|
+
with `launchctl getenv`, never `os.environ`: Resolve is GUI-launched and
|
|
33
|
+
inherits launchd's environment, so reading our own shell would report a hit in
|
|
34
|
+
exactly the case that does not work. A `PYTHON3HOME` exported in the shell but
|
|
35
|
+
absent from launchd is called out, because it is the natural thing to try. The
|
|
36
|
+
old advice is corrected in the Lua canary, the `BRIDGE_UNAVAILABLE`
|
|
37
|
+
remediation, both READMEs and `docs/SKILL.md`.
|
|
38
|
+
|
|
39
|
+
### Added
|
|
40
|
+
|
|
41
|
+
- **Domain skills ask the build what it cannot do.** v2.89.0 taught
|
|
42
|
+
`get_version` to report a connected build's missing surfaces, but only the
|
|
43
|
+
session skill ever asked — an agent entering through
|
|
44
|
+
`/timeline_edit_workflow` or `/color_grade_workflow` got identical guidance
|
|
45
|
+
whatever it was attached to. That is the hole @magwa101 fell into on DR 21 in
|
|
46
|
+
#132. `resolve-edit`, `resolve-color`, `resolve-conform` and
|
|
47
|
+
`resolve-media-analysis` now name the gated surfaces in their own domain, with
|
|
48
|
+
the floor and what to do instead, sourced from `resolve_versions.VERSION_GATES`
|
|
49
|
+
rather than prose. Each section also has to say that an empty list means
|
|
50
|
+
nothing *recorded* is missing, that probes use `name in dir(obj)` and never
|
|
51
|
+
bare `hasattr` (which returns `True` for every name on a Resolve object), and
|
|
52
|
+
that *gated* is not *absent* — clip speed is unreachable on every build and no
|
|
53
|
+
upgrade will help.
|
|
54
|
+
- `tests/test_skill_version_gates.py` fails when a skill quotes a floor the
|
|
55
|
+
ledger disagrees with. Confirmed it bites by mis-stating a floor and watching
|
|
56
|
+
it fail, not by trusting a green run.
|
|
57
|
+
|
|
58
|
+
### Validation
|
|
59
|
+
|
|
60
|
+
- Suite: 2621 passed, 1 skipped. Static checks and drift guards clean.
|
|
61
|
+
- The binary strings and the `/usr/local/bin/python3` mechanism were confirmed
|
|
62
|
+
here on Studio 19.1.3.7. **Not reproduced here:** the live positive on free
|
|
63
|
+
21.0.4.5 with uv Python — that is the reporter's, and is labelled as such in
|
|
64
|
+
the code comment.
|
|
65
|
+
- No Resolve scripting behavior changed; the bridge change is installer
|
|
66
|
+
preflight and advice text.
|
|
67
|
+
|
|
68
|
+
## What's New in v2.91.0
|
|
69
|
+
|
|
70
|
+
A timeline item's source frames are counted in the **media's** frame rate, not
|
|
71
|
+
the timeline's — and a WAV carries no native rate, so Resolve reports **24** for
|
|
72
|
+
it. Read back at the timeline rate a WAV offset lands minutes from the real
|
|
73
|
+
position in the file, and nothing errors: `source_end` is derived as
|
|
74
|
+
`source_start + timeline_duration`, so the start/end pair stays internally
|
|
75
|
+
consistent whatever rate the caller assumed. Reported and measured by
|
|
76
|
+
@rusanivsky in #144.
|
|
77
|
+
|
|
78
|
+
### Added
|
|
79
|
+
|
|
80
|
+
- **`source_fps` beside the frames.** Every timeline-item summary now carries
|
|
81
|
+
the rate its source frames are counted in, plus `source_start_seconds` /
|
|
82
|
+
`source_end_seconds`, so the frame number always arrives with its unit
|
|
83
|
+
attached. The rate is read from the media-pool item's `FPS` property, never
|
|
84
|
+
assumed; an unreadable rate reports `null` so callers see *unknown* rather
|
|
85
|
+
than a guess.
|
|
86
|
+
- **The reader that produced the value is tracked**, because the two do not
|
|
87
|
+
agree on units: on an audio item `GetLeftOffset` counts in **timeline** frames
|
|
88
|
+
while `GetSourceStartFrame` counts in **source** frames — 60687 vs 75784 for
|
|
89
|
+
the same edit point. On that fallback the summary reports `source_fps: null`
|
|
90
|
+
rather than pairing a timeline-frame number with the media rate.
|
|
91
|
+
- **`create_variant_from_ranges`' per-range `track_index`** — 1-based within
|
|
92
|
+
`track_type`, missing tracks added — was accepted but undocumented, so
|
|
93
|
+
multicam angles collapsed onto V1 for anyone who did not read the source. Now
|
|
94
|
+
in the action help, the action list, the example, and `docs/SKILL.md`.
|
|
95
|
+
- The trap is in the `api_truth` ledger and in `resolve-rough-cut`'s verified
|
|
96
|
+
traps table.
|
|
97
|
+
|
|
98
|
+
### Fixed
|
|
99
|
+
|
|
100
|
+
- **`source_end_seconds` was the same unit lie the field was added to stop.**
|
|
101
|
+
`source_end` is `source_start + duration`, and that duration comes from
|
|
102
|
+
`GetDuration` — a **timeline** duration. Converting the sum at the media rate
|
|
103
|
+
compounds the very mix-up being guarded. The seconds now come from
|
|
104
|
+
`GetSourceStartTime` / `GetSourceEndTime`, which answer in seconds with no
|
|
105
|
+
rate inference at all, then from `GetSourceEndFrame / source_fps`, and read
|
|
106
|
+
`null` rather than convert the derived value. `source_end` itself is
|
|
107
|
+
unchanged — no consumer moves — but it is now annotated as unit-mixed where
|
|
108
|
+
it is assigned, in the probe action help, and in the ledger.
|
|
109
|
+
|
|
110
|
+
### Validation
|
|
111
|
+
|
|
112
|
+
- Suite: 2613 passed, 1 skipped. `gen_api_limitations.py --check` clean.
|
|
113
|
+
- **Live on Studio 19.1.3.7** with synthetic media, which also shows the trap is
|
|
114
|
+
not a 21.x regression. A 300 s 48 kHz WAV reports `FPS 24`; appending its
|
|
115
|
+
source frames 4800–5235 to a 29.97 fps timeline yields a timeline duration of
|
|
116
|
+
**543** (= 435 × 29.97/24), so `source_end` came back **5343** where the true
|
|
117
|
+
source end is 5235 — `source_end / 24` reports **222.625 s** against a real
|
|
118
|
+
**218.133 s**, 4.49 s out on a clip 18.1 s long. The patched code reports
|
|
119
|
+
218.133 s. The matching 29.97 video item was unaffected either way (24.524 s
|
|
120
|
+
read vs 24.525 s derived). Both second-readers exist on 19.1.3.7.
|
|
121
|
+
- Not tested here: the 21.0.3.7 measurements in the ledger entry, which are
|
|
122
|
+
@rusanivsky's and are labelled as such.
|
|
123
|
+
|
|
5
124
|
## What's New in v2.90.0
|
|
6
125
|
|
|
7
126
|
AAF turnovers parsed by `editorial.parse_interchange` on the advanced server now
|
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
English | [简体中文](README.zh-CN.md)
|
|
4
4
|
|
|
5
|
-
[](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
|
|
6
6
|
[](https://www.npmjs.com/package/davinci-resolve-mcp)
|
|
7
7
|
[](docs/reference/api-coverage.md)
|
|
8
8
|
[-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**,
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
menu. A
|
|
66
|
-
|
|
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 —
|
|
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
|
-
[](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
|
|
6
6
|
[](https://www.npmjs.com/package/davinci-resolve-mcp)
|
|
7
7
|
[](docs/reference/api-coverage.md)
|
|
8
8
|
[-blue.svg)](#服务器模式)
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
[](https://www.python.org/downloads/)
|
|
13
13
|
[](https://opensource.org/licenses/MIT)
|
|
14
14
|
|
|
15
|
-
> 本翻译对应 v2.
|
|
15
|
+
> 本翻译对应 v2.92.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**
|
|
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
|
-
|
|
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
|
|
34
|
-
|
|
35
|
-
simply never
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
|
@@ -1381,7 +1386,9 @@ Key actions:
|
|
|
1381
1386
|
into an editor-facing beat report
|
|
1382
1387
|
- `create_variant_from_ranges(name, ranges, markers?, cdl?, dry_run?)` — create
|
|
1383
1388
|
a guarded timeline variant from declarative source ranges, optional markers,
|
|
1384
|
-
transforms, and CDL
|
|
1389
|
+
transforms, and CDL. Each range takes `track_type?` and a 1-based
|
|
1390
|
+
`track_index?` (default 1), so multicam angles can be rebuilt onto V2/V3
|
|
1391
|
+
rather than collapsing onto V1; missing tracks are added
|
|
1385
1392
|
- `bulk_set_item_properties(ops, dry_run?, readback?)` — apply transforms,
|
|
1386
1393
|
crop/composite/audio/property groups to many timeline items in one call
|
|
1387
1394
|
- `apply_look_to_items(target_ids, cdl?|copy_from_item_id?, dry_run?)` — apply a
|
|
@@ -12,7 +12,7 @@ that none exists).
|
|
|
12
12
|
|
|
13
13
|
**Verified on:** DaVinci Resolve Studio 21.0.2
|
|
14
14
|
|
|
15
|
-
**Totals:** 27 missing capabilities,
|
|
15
|
+
**Totals:** 27 missing capabilities, 33 bugs / unreliable behaviors.
|
|
16
16
|
|
|
17
17
|
The authoritative source is the runtime-queryable `api_truth` ledger
|
|
18
18
|
(`resolve_control api_truth "<query>"`); this document is generated from
|
|
@@ -382,6 +382,14 @@ 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)
|
|
386
|
+
|
|
387
|
+
- **Object:** `TimelineItem`
|
|
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.
|
|
391
|
+
- **Tags:** timeline, audio, wav, frame-rate, mixed-fps, silent-failure, readback
|
|
392
|
+
|
|
385
393
|
### Studio-gated calls on the free edition raise a modal that blocks LATER calls
|
|
386
394
|
|
|
387
395
|
- **Object:** `Resolve (all objects)`
|
package/install.py
CHANGED
|
@@ -36,7 +36,7 @@ from src.utils.update_check import (
|
|
|
36
36
|
|
|
37
37
|
# ─── Version ──────────────────────────────────────────────────────────────────
|
|
38
38
|
|
|
39
|
-
VERSION = "2.
|
|
39
|
+
VERSION = "2.92.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
|
@@ -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
|
|
227
|
-
#:
|
|
228
|
-
#: the
|
|
229
|
-
#:
|
|
230
|
-
#:
|
|
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
|
-
--
|
|
240
|
-
|
|
241
|
-
|
|
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
|
|
265
|
-
|
|
266
|
-
registry, or the system interpreter). Running the macOS check
|
|
267
|
-
found nothing and emitted the macOS remediation — telling the
|
|
268
|
-
in issue #106, who had a working python.org 3.12, to go
|
|
269
|
-
they already had. Report "no known reason it will not
|
|
270
|
-
than a false alarm; the Lua canary still ships either
|
|
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
|
-
"
|
|
283
|
-
"
|
|
284
|
-
|
|
285
|
-
|
|
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
|
|
package/src/granular/common.py
CHANGED
|
@@ -87,7 +87,7 @@ if not logging.getLogger().handlers:
|
|
|
87
87
|
handlers=[logging.StreamHandler()],
|
|
88
88
|
)
|
|
89
89
|
|
|
90
|
-
VERSION = "2.
|
|
90
|
+
VERSION = "2.92.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.
|
|
14
|
+
VERSION = "2.92.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
|
|
984
|
-
"
|
|
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:
|
|
@@ -2992,18 +2994,31 @@ def _safe_media_pool_item_name(mpi):
|
|
|
2992
2994
|
return None
|
|
2993
2995
|
|
|
2994
2996
|
|
|
2995
|
-
def
|
|
2997
|
+
def _timeline_item_source_start_with_origin(item):
|
|
2998
|
+
"""(source_start, origin) — which reader the frame number came from.
|
|
2999
|
+
|
|
3000
|
+
The origin matters because the two readers do not agree on units. On an
|
|
3001
|
+
AUDIO item, measured on Studio 21.0.3.7 across 12 items of one WAV,
|
|
3002
|
+
GetSourceStartFrame advances at exactly 24.000 fps against the item's own
|
|
3003
|
+
GetSourceStartTime (the media's rate) while GetLeftOffset advances at 29.970
|
|
3004
|
+
— the TIMELINE rate. Same edit point, different frame spaces. Callers that
|
|
3005
|
+
attach a rate to the number must know which reader produced it.
|
|
3006
|
+
"""
|
|
2996
3007
|
if _has_method(item, "GetSourceStartFrame"):
|
|
2997
3008
|
try:
|
|
2998
3009
|
source_start = _frame_int(item.GetSourceStartFrame())
|
|
2999
3010
|
if source_start is not None:
|
|
3000
|
-
return source_start
|
|
3011
|
+
return source_start, "GetSourceStartFrame"
|
|
3001
3012
|
except Exception:
|
|
3002
3013
|
pass
|
|
3003
3014
|
try:
|
|
3004
|
-
return _frame_int(item.GetLeftOffset())
|
|
3015
|
+
return _frame_int(item.GetLeftOffset()), "GetLeftOffset"
|
|
3005
3016
|
except Exception:
|
|
3006
|
-
return None
|
|
3017
|
+
return None, None
|
|
3018
|
+
|
|
3019
|
+
|
|
3020
|
+
def _timeline_item_source_start(item):
|
|
3021
|
+
return _timeline_item_source_start_with_origin(item)[0]
|
|
3007
3022
|
|
|
3008
3023
|
|
|
3009
3024
|
def _timeline_item_media_pool_item(item):
|
|
@@ -3039,7 +3054,103 @@ def _timeline_item_track_info(item):
|
|
|
3039
3054
|
return None, _err("invalid source track index")
|
|
3040
3055
|
|
|
3041
3056
|
|
|
3042
|
-
def
|
|
3057
|
+
def _media_item_source_fps(media_pool_item, clip_properties=None):
|
|
3058
|
+
"""The frame rate a media-pool item's SOURCE frames are counted in.
|
|
3059
|
+
|
|
3060
|
+
Source frames (GetSourceStartFrame / GetLeftOffset) are expressed in the
|
|
3061
|
+
MEDIA's own rate, never the timeline's — and a WAV carries no native rate,
|
|
3062
|
+
so Resolve reports 24 for it. Read the rate instead of assuming the
|
|
3063
|
+
timeline's: at 29.97 a WAV offset lands minutes away from the real position
|
|
3064
|
+
in the file, and nothing errors (see the api_truth entry "GetSourceStartFrame
|
|
3065
|
+
on an AUDIO item"). Returns None when the rate cannot be read, so callers
|
|
3066
|
+
surface "unknown" rather than a guess.
|
|
3067
|
+
|
|
3068
|
+
Pass ``clip_properties`` when the caller already holds the item's property
|
|
3069
|
+
dict — the probe path does, so this costs it no extra bridge call.
|
|
3070
|
+
"""
|
|
3071
|
+
value = None
|
|
3072
|
+
if isinstance(clip_properties, dict):
|
|
3073
|
+
value = clip_properties.get("FPS")
|
|
3074
|
+
if value in (None, "") and media_pool_item is not None:
|
|
3075
|
+
try:
|
|
3076
|
+
value = media_pool_item.GetClipProperty("FPS")
|
|
3077
|
+
except Exception:
|
|
3078
|
+
value = None
|
|
3079
|
+
if isinstance(value, dict): # GetClipProperty("") returns the whole map
|
|
3080
|
+
value = value.get("FPS")
|
|
3081
|
+
try:
|
|
3082
|
+
fps = float(value)
|
|
3083
|
+
except (TypeError, ValueError):
|
|
3084
|
+
return None
|
|
3085
|
+
return fps if fps > 0 else None
|
|
3086
|
+
|
|
3087
|
+
|
|
3088
|
+
def _source_frames_to_seconds(frames, fps):
|
|
3089
|
+
"""Source frames -> seconds into the file, or None when either is unknown."""
|
|
3090
|
+
if frames is None or not fps:
|
|
3091
|
+
return None
|
|
3092
|
+
return round(frames / fps, 3)
|
|
3093
|
+
|
|
3094
|
+
|
|
3095
|
+
def _timeline_item_source_time(item, method):
|
|
3096
|
+
"""Resolve's own source-time reader (GetSourceStartTime/GetSourceEndTime).
|
|
3097
|
+
|
|
3098
|
+
Seconds into the source file, read directly — no rate inference, so it is
|
|
3099
|
+
the authoritative answer whenever the build exposes it. None when the
|
|
3100
|
+
method is absent or unreadable, leaving the caller to fall back to
|
|
3101
|
+
frames / source_fps.
|
|
3102
|
+
"""
|
|
3103
|
+
if not _has_method(item, method):
|
|
3104
|
+
return None
|
|
3105
|
+
try:
|
|
3106
|
+
value = getattr(item, method)()
|
|
3107
|
+
except Exception:
|
|
3108
|
+
return None
|
|
3109
|
+
try:
|
|
3110
|
+
seconds = float(value)
|
|
3111
|
+
except (TypeError, ValueError):
|
|
3112
|
+
return None
|
|
3113
|
+
return round(seconds, 3)
|
|
3114
|
+
|
|
3115
|
+
|
|
3116
|
+
def _timeline_item_source_end_frame(item):
|
|
3117
|
+
"""The item's SOURCE-space end frame, from Resolve's own reader.
|
|
3118
|
+
|
|
3119
|
+
Distinct from the ``source_end`` this module derives as
|
|
3120
|
+
``source_start + duration``: that duration is a TIMELINE duration
|
|
3121
|
+
(GetDuration), so the sum mixes units the moment the media rate differs
|
|
3122
|
+
from the timeline's — always, for a WAV. Only a genuine source-space frame
|
|
3123
|
+
may be divided by source_fps.
|
|
3124
|
+
"""
|
|
3125
|
+
if not _has_method(item, "GetSourceEndFrame"):
|
|
3126
|
+
return None
|
|
3127
|
+
try:
|
|
3128
|
+
return _frame_int(item.GetSourceEndFrame())
|
|
3129
|
+
except Exception:
|
|
3130
|
+
return None
|
|
3131
|
+
|
|
3132
|
+
|
|
3133
|
+
def _timeline_item_source_seconds(item, source_start, source_end, source_fps):
|
|
3134
|
+
"""(start_seconds, end_seconds) into the source file, or None each.
|
|
3135
|
+
|
|
3136
|
+
Prefers Resolve's second-readers, then a source-space frame divided by the
|
|
3137
|
+
media rate. The derived ``source_end`` is deliberately NOT a fallback: it
|
|
3138
|
+
is ``source_start + timeline_duration``, so on a 24 fps WAV in a 29.97 fps
|
|
3139
|
+
timeline it overstates the clip's span by 25% (18.1 s reported for a
|
|
3140
|
+
14.5 s clip). An unknown end reads as unknown.
|
|
3141
|
+
"""
|
|
3142
|
+
start_seconds = _timeline_item_source_time(item, "GetSourceStartTime")
|
|
3143
|
+
if start_seconds is None:
|
|
3144
|
+
start_seconds = _source_frames_to_seconds(source_start, source_fps)
|
|
3145
|
+
end_seconds = _timeline_item_source_time(item, "GetSourceEndTime")
|
|
3146
|
+
if end_seconds is None:
|
|
3147
|
+
end_seconds = _source_frames_to_seconds(
|
|
3148
|
+
_timeline_item_source_end_frame(item), source_fps)
|
|
3149
|
+
return start_seconds, end_seconds
|
|
3150
|
+
|
|
3151
|
+
|
|
3152
|
+
def _timeline_item_summary(item, track_info=None, *, media_pool_item=None,
|
|
3153
|
+
clip_properties=None):
|
|
3043
3154
|
if not item:
|
|
3044
3155
|
return None
|
|
3045
3156
|
start = end = duration = source_start = source_end = None
|
|
@@ -3049,12 +3160,23 @@ def _timeline_item_summary(item, track_info=None):
|
|
|
3049
3160
|
except Exception:
|
|
3050
3161
|
pass
|
|
3051
3162
|
duration = _timeline_item_duration(item, start, end)
|
|
3052
|
-
source_start =
|
|
3163
|
+
source_start, source_start_origin = _timeline_item_source_start_with_origin(item)
|
|
3053
3164
|
if source_start is not None and duration is not None:
|
|
3054
3165
|
source_end = source_start + duration
|
|
3055
3166
|
if track_info is None:
|
|
3056
3167
|
track_info, _ = _timeline_item_track_info(item)
|
|
3057
|
-
media_pool_item
|
|
3168
|
+
if media_pool_item is None:
|
|
3169
|
+
media_pool_item = _timeline_item_media_pool_item(item)
|
|
3170
|
+
# source_* are in the MEDIA's frame rate; report it and the derived seconds
|
|
3171
|
+
# so a caller never has to guess which rate the frame numbers are in.
|
|
3172
|
+
source_fps = _media_item_source_fps(media_pool_item, clip_properties)
|
|
3173
|
+
if source_start_origin == "GetLeftOffset" and (track_info or (None,))[0] == "audio":
|
|
3174
|
+
# GetLeftOffset counts an audio item in TIMELINE frames, so pairing it
|
|
3175
|
+
# with the media rate would produce a confidently wrong number. Report
|
|
3176
|
+
# the frame and leave the rate unknown rather than convert it wrong.
|
|
3177
|
+
source_fps = None
|
|
3178
|
+
source_start_seconds, source_end_seconds = _timeline_item_source_seconds(
|
|
3179
|
+
item, source_start, source_end, source_fps)
|
|
3058
3180
|
summary = {
|
|
3059
3181
|
"timeline_item_id": _safe_timeline_item_id(item),
|
|
3060
3182
|
"name": _safe_timeline_item_name(item),
|
|
@@ -3064,7 +3186,13 @@ def _timeline_item_summary(item, track_info=None):
|
|
|
3064
3186
|
"end": end,
|
|
3065
3187
|
"duration": duration,
|
|
3066
3188
|
"source_start": source_start,
|
|
3189
|
+
# NOTE: derived as source_start + TIMELINE duration, so it is unit-mixed
|
|
3190
|
+
# whenever source_fps != the timeline rate. Kept for compatibility; use
|
|
3191
|
+
# source_end_seconds, which is read from source space, for real time.
|
|
3067
3192
|
"source_end": source_end,
|
|
3193
|
+
"source_fps": source_fps,
|
|
3194
|
+
"source_start_seconds": source_start_seconds,
|
|
3195
|
+
"source_end_seconds": source_end_seconds,
|
|
3068
3196
|
"media_pool_item_id": _safe_media_pool_item_id(media_pool_item),
|
|
3069
3197
|
"media_pool_item_name": _safe_media_pool_item_name(media_pool_item),
|
|
3070
3198
|
}
|
|
@@ -5008,8 +5136,9 @@ def _conform_capabilities():
|
|
|
5008
5136
|
|
|
5009
5137
|
|
|
5010
5138
|
def _timeline_item_conform_summary(item, track_type: str, track_index: int, item_index: int):
|
|
5011
|
-
|
|
5012
|
-
summary
|
|
5139
|
+
# Fetch the media-pool item and its properties FIRST, then hand both to the
|
|
5140
|
+
# summary: it needs the 'FPS' property for source_fps, and this way the probe
|
|
5141
|
+
# pays for one GetMediaPoolItem/GetClipProperty pair per item, not two.
|
|
5013
5142
|
media_pool_item = _timeline_item_media_pool_item(item)
|
|
5014
5143
|
file_path = None
|
|
5015
5144
|
clip_properties = None
|
|
@@ -5019,12 +5148,17 @@ def _timeline_item_conform_summary(item, track_type: str, track_index: int, item
|
|
|
5019
5148
|
clip_properties = _ser(media_pool_item.GetClipProperty(""))
|
|
5020
5149
|
except Exception:
|
|
5021
5150
|
clip_properties = None
|
|
5022
|
-
|
|
5023
|
-
|
|
5024
|
-
|
|
5025
|
-
|
|
5026
|
-
|
|
5027
|
-
|
|
5151
|
+
summary = _timeline_item_summary(
|
|
5152
|
+
item, (track_type, track_index),
|
|
5153
|
+
media_pool_item=media_pool_item, clip_properties=clip_properties,
|
|
5154
|
+
) or {}
|
|
5155
|
+
summary["item_index"] = item_index
|
|
5156
|
+
if isinstance(clip_properties, dict):
|
|
5157
|
+
file_path = clip_properties.get("File Path") or clip_properties.get("FilePath")
|
|
5158
|
+
for key in ("Status", "Media Status", "Offline", "Online Status"):
|
|
5159
|
+
if key in clip_properties:
|
|
5160
|
+
media_status = clip_properties.get(key)
|
|
5161
|
+
break
|
|
5028
5162
|
summary["file_path"] = file_path
|
|
5029
5163
|
summary["file_exists"] = bool(file_path and os.path.exists(str(file_path)))
|
|
5030
5164
|
summary["media_status"] = media_status
|
|
@@ -5409,7 +5543,8 @@ def _timeline_apply_look_to_items(tl, p: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
5409
5543
|
def _variant_item_placement(item) -> Dict[str, Any]:
|
|
5410
5544
|
"""Report an appended item's placed frame positions in both frame spaces.
|
|
5411
5545
|
record_* are TIMELINE frames (GetStart/GetEnd/GetDuration); source_start is
|
|
5412
|
-
a SOURCE frame
|
|
5546
|
+
a SOURCE frame, counted in source_fps — the MEDIA's rate, which for a WAV is
|
|
5547
|
+
24 and not the timeline's."""
|
|
5413
5548
|
def _read(method):
|
|
5414
5549
|
fn = getattr(item, method, None)
|
|
5415
5550
|
if not callable(fn):
|
|
@@ -5423,11 +5558,16 @@ def _variant_item_placement(item) -> Dict[str, Any]:
|
|
|
5423
5558
|
duration = _read("GetDuration")
|
|
5424
5559
|
if duration is None and record_start is not None and record_end is not None:
|
|
5425
5560
|
duration = record_end - record_start
|
|
5561
|
+
source_start = _timeline_item_source_start(item)
|
|
5562
|
+
source_fps = _media_item_source_fps(_timeline_item_media_pool_item(item))
|
|
5563
|
+
source_start_seconds, _ = _timeline_item_source_seconds(item, source_start, None, source_fps)
|
|
5426
5564
|
return {
|
|
5427
5565
|
"record_start": record_start,
|
|
5428
5566
|
"record_end": record_end,
|
|
5429
5567
|
"duration": duration,
|
|
5430
|
-
"source_start":
|
|
5568
|
+
"source_start": source_start,
|
|
5569
|
+
"source_fps": source_fps,
|
|
5570
|
+
"source_start_seconds": source_start_seconds,
|
|
5431
5571
|
}
|
|
5432
5572
|
|
|
5433
5573
|
|
|
@@ -21393,7 +21533,10 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
|
|
|
21393
21533
|
Frame numbers are TIMELINE/record frames (position on the timeline) unless an action
|
|
21394
21534
|
says SOURCE. Source frames are positions within a media-pool clip's own media:
|
|
21395
21535
|
create_variant_from_ranges takes SOURCE start_frame/end_frame; extract_source_frame_ranges
|
|
21396
|
-
and source_range_report return SOURCE ranges.
|
|
21536
|
+
and source_range_report return SOURCE ranges. A SOURCE frame is counted in the MEDIA's own
|
|
21537
|
+
frame rate, not the timeline's: an AUDIO item's source_start/source_end read back in the
|
|
21538
|
+
file's rate, and a WAV (no native rate) defaults to 24 fps, so converting one at the timeline
|
|
21539
|
+
rate is silently wrong by minutes (resolve_control api_truth "GetSourceStartFrame").
|
|
21397
21540
|
|
|
21398
21541
|
Actions:
|
|
21399
21542
|
list() -> {timelines}
|
|
@@ -21449,6 +21592,8 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
|
|
|
21449
21592
|
nothing moves. (frames here are TIMELINE/record frames.)
|
|
21450
21593
|
story_spine_report() -> {beats, track_summaries, source_ranges, audio_spine}
|
|
21451
21594
|
create_variant_from_ranges(name, ranges, markers?, cdl?, dry_run?) -> {success, id, items}
|
|
21595
|
+
ranges[] take track_type? (video|audio) and track_index? (1-based, within the
|
|
21596
|
+
track_type, default 1); missing tracks are added, so V2/V3 multicam angles survive.
|
|
21452
21597
|
# example: action_help(name='<action_name>')
|
|
21453
21598
|
bulk_set_item_properties(ops, dry_run?, readback?) -> {results, op_count}
|
|
21454
21599
|
# example: action_help(name='<action_name>')
|
|
@@ -21511,6 +21656,14 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
|
|
|
21511
21656
|
Default handles=24, gap_max=30. Use handles=0 for gap-only auto handles.
|
|
21512
21657
|
conform_capabilities() -> {supported, partially_supported, unsupported, export_aliases}
|
|
21513
21658
|
probe_timeline_structure(track_types?, include_markers?, include_clip_properties?) -> {tracks, markers}
|
|
21659
|
+
Each item reports source_start in the MEDIA's frame rate, the source_fps it is
|
|
21660
|
+
counted in, and source_start_seconds/source_end_seconds. Use those seconds — a
|
|
21661
|
+
WAV counts at 24 fps on any timeline, so dividing by the timeline rate is wrong
|
|
21662
|
+
by minutes. source_fps is null when the rate could not be read; treat the frames
|
|
21663
|
+
as unitless then, do not assume the timeline's. source_end is derived as
|
|
21664
|
+
source_start + TIMELINE duration, so it is unit-mixed when the rates differ —
|
|
21665
|
+
the seconds come from Resolve's own source-time readers instead, and read null
|
|
21666
|
+
rather than convert the derived value.
|
|
21514
21667
|
detect_gaps_overlaps(track_types?, min_gap?) -> {gaps, overlaps}
|
|
21515
21668
|
source_range_report(handles?, merge?) -> {ranges, occurrences}
|
|
21516
21669
|
export_timeline_checked(path, format?|type?, subtype?, require_temp_path?, dry_run?, background?) -> {success, path, size | job_id}
|
|
@@ -23334,9 +23487,16 @@ _ACTION_HELP: Dict[str, Dict[str, Dict[str, Any]]] = {
|
|
|
23334
23487
|
"summary": "Build a variant timeline from N source ranges. Video-only unless ranges include track_type='audio'. Source-safe; dry_run validates clip ids and frame ranges.",
|
|
23335
23488
|
"params": (
|
|
23336
23489
|
"name, ranges: [{clip_id|media_pool_item_id, start_frame, end_frame, "
|
|
23337
|
-
"record_frame?, track_type?}], pack?, markers?, cdl?, dry_run? — clip_id is a "
|
|
23490
|
+
"record_frame?, track_type?, track_index?}], pack?, markers?, cdl?, dry_run? — clip_id is a "
|
|
23338
23491
|
"media-pool item id (not a timeline-item id); start_frame/end_frame are SOURCE "
|
|
23339
23492
|
"frames, end_frame exclusive (source duration = end_frame - start_frame). "
|
|
23493
|
+
"track_index is the 1-based destination track WITHIN track_type (default 1); the "
|
|
23494
|
+
"variant is created with enough video/audio tracks to cover the highest index used, "
|
|
23495
|
+
"so multicam angles can be rebuilt onto V2/V3 instead of collapsing onto V1. "
|
|
23496
|
+
"SOURCE frames are counted in the MEDIA's frame rate, not the timeline's — an audio "
|
|
23497
|
+
"item's read back as 24 fps for a WAV (api_truth \"GetSourceStartFrame on an AUDIO "
|
|
23498
|
+
"item\"); pass them in that space, placement converts and items[].duration_delta "
|
|
23499
|
+
"reports the conversion. "
|
|
23340
23500
|
"pack=true butts clips together at the end of each track (gap-free, ignores record_frame)"
|
|
23341
23501
|
),
|
|
23342
23502
|
"returns": "{success, id, items} — items[].placed = placed frames; items[].range = the requested range",
|
|
@@ -23344,8 +23504,11 @@ _ACTION_HELP: Dict[str, Dict[str, Dict[str, Any]]] = {
|
|
|
23344
23504
|
'timeline(action="create_variant_from_ranges", params={\n'
|
|
23345
23505
|
' "name": "v02_tighter_act1",\n'
|
|
23346
23506
|
' "ranges": [\n'
|
|
23347
|
-
' {"clip_id": "<
|
|
23348
|
-
' {"clip_id": "<
|
|
23507
|
+
' {"clip_id": "<cam1-id>", "start_frame": 1200, "end_frame": 1320},\n'
|
|
23508
|
+
' {"clip_id": "<cam3-id>", "start_frame": 1500, "end_frame": 1600,\n'
|
|
23509
|
+
' "track_index": 2},\n'
|
|
23510
|
+
' {"clip_id": "<wav-id>", "track_type": "audio", "track_index": 1,\n'
|
|
23511
|
+
' "start_frame": 56871, "end_frame": 57591} # 24 fps source frames\n'
|
|
23349
23512
|
' ],\n'
|
|
23350
23513
|
' "dry_run": True\n'
|
|
23351
23514
|
'})'
|
package/src/utils/api_truth.py
CHANGED
|
@@ -660,6 +660,92 @@ API_TRUTH: List[Dict[str, Any]] = [
|
|
|
660
660
|
"tags": ["off-by-one", "unreliable-return", "timeline", "conform", "verify"],
|
|
661
661
|
"submit": "bug",
|
|
662
662
|
},
|
|
663
|
+
{
|
|
664
|
+
"symbol": "TimelineItem.GetSourceStartFrame on an AUDIO item (media-rate frames; 24 fps for WAV)",
|
|
665
|
+
"object": "TimelineItem",
|
|
666
|
+
"signature": "() -> int # source frame, counted in the MEDIA's frame rate",
|
|
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 "
|
|
670
|
+
"minutes away from the real position in the file. Verified live "
|
|
671
|
+
"on Studio 21.0.3.7 (2026-08-09, 29.97 fps timeline): a "
|
|
672
|
+
"ZOOM0028.WAV item reported source_start 56871, which is "
|
|
673
|
+
"56871 / 24 = 2369.6 s into the file, NOT the 1897.6 s a 29.97 "
|
|
674
|
+
"fps reading gives — a 471.9 s (7 min 52 s) error. Nothing looks "
|
|
675
|
+
"wrong, because timeline probe_timeline_structure derives "
|
|
676
|
+
"source_end as source_start + timeline_duration: the start/end "
|
|
677
|
+
"pair stays internally consistent whatever rate you assume. "
|
|
678
|
+
"VIDEO items are NOT affected — two 29.97 fps items "
|
|
679
|
+
"(KR020007.MOV, IMG_0001.mov) on the same timeline reported "
|
|
680
|
+
"source frames in their own, matching rate, confirmed against "
|
|
681
|
+
"ffprobe durations and span arithmetic. This is the read-side "
|
|
682
|
+
"twin of the AppendToTimeline mixed-fps entry below: that one is "
|
|
683
|
+
"about writing source frames whose rate differs from the "
|
|
684
|
+
"timeline's, this one about reading them back and not knowing "
|
|
685
|
+
"which rate they are in. The rate was pinned by regression, "
|
|
686
|
+
"not assumed: across 12 items of the same WAV, "
|
|
687
|
+
"GetSourceStartFrame advances at 24.000 fps against the item's "
|
|
688
|
+
"own GetSourceStartTime (24.0000/24.0007/23.9995 over spans up "
|
|
689
|
+
"to 22 minutes). The same measurement exposed a second unit "
|
|
690
|
+
"trap: on an AUDIO item GetLeftOffset advances at 29.970 — the "
|
|
691
|
+
"TIMELINE rate — so the two readers describe the same edit "
|
|
692
|
+
"point in DIFFERENT frame spaces (60687 vs 75784 for one "
|
|
693
|
+
"item). On video they share the source space. Caveat on the "
|
|
694
|
+
"absolute zero: Resolve's model of this file is 133003 frames "
|
|
695
|
+
"(Duration 01:32:21:19 at 24 fps = 5541.79 s) while its true "
|
|
696
|
+
"PCM length is 266264768 samples / 48 kHz = 5547.18 s, a 0.097% "
|
|
697
|
+
"difference we have not explained — so frames/24 is exact in "
|
|
698
|
+
"Resolve's source-time space, which is the space every other "
|
|
699
|
+
"Resolve call uses, but may sit ~2 s off the byte position in "
|
|
700
|
+
"a 40-minute-deep offset. Re-confirmed on Studio 19.1.3.7 "
|
|
701
|
+
"(2026-08-10) with synthetic media, so this is not a 21.x "
|
|
702
|
+
"regression: a 300 s 48 kHz WAV reports FPS 24, and appending "
|
|
703
|
+
"source frames 4800-5235 of it to a 29.97 fps timeline yields "
|
|
704
|
+
"a timeline duration of 543 (= 435 x 29.97/24), which is the "
|
|
705
|
+
"conversion happening in the open. The same run measured the "
|
|
706
|
+
"cost of the derived end: source_end came back 5343 "
|
|
707
|
+
"(4800 + 543) where the true source end is 5235, so "
|
|
708
|
+
"source_end / 24 reports 222.625 s against a real 218.133 s "
|
|
709
|
+
"from GetSourceEndTime — 4.49 s out, on a clip only 18.1 s "
|
|
710
|
+
"long. GetSourceStartTime read exactly 200.0 s (= 4800/24) on "
|
|
711
|
+
"the same item. The matching VIDEO item (29.97 source in a "
|
|
712
|
+
"29.97 timeline) was unaffected in both: 24.524 s read against "
|
|
713
|
+
"24.525 s derived. Both second-readers exist on 19.1.3.7, so "
|
|
714
|
+
"the GetSourceEndFrame fallback below is for builds older "
|
|
715
|
+
"still.",
|
|
716
|
+
"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 "
|
|
722
|
+
"create_variant_from_ranges in the same media-rate space you "
|
|
723
|
+
"read them in; it converts on placement and reports the "
|
|
724
|
+
"conversion in items[].duration_delta. The separate "
|
|
725
|
+
"GetSourceStartFrame entry above (off-by-one vs "
|
|
726
|
+
"GetLeftOffset) applies on top of this — the rate question "
|
|
727
|
+
"is which unit the number is in, not whether it is exact. "
|
|
728
|
+
"Mitigated in-process: _timeline_item_summary now emits "
|
|
729
|
+
"source_fps and source_start_seconds/source_end_seconds "
|
|
730
|
+
"beside the frames, so the number always arrives with its "
|
|
731
|
+
"unit; on the GetLeftOffset fallback for an audio item it "
|
|
732
|
+
"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.",
|
|
743
|
+
"tags": ["timeline", "audio", "wav", "frame-rate", "mixed-fps",
|
|
744
|
+
"silent-failure", "readback"],
|
|
745
|
+
"submit": "bug",
|
|
746
|
+
"mitigation": ["_media_item_source_fps", "_source_frames_to_seconds",
|
|
747
|
+
"_timeline_item_source_seconds"],
|
|
748
|
+
},
|
|
663
749
|
{
|
|
664
750
|
"symbol": "Razor / blade / split a timeline item",
|
|
665
751
|
"object": "Timeline / TimelineItem",
|