davinci-resolve-mcp 2.97.2 → 2.97.4

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,120 @@
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.97.4
6
+
7
+ **Drift is caught at the edit, not at publish time.** Tooling and docs only —
8
+ no server behavior changed, and no Resolve live run was required or performed.
9
+
10
+ ### Added
11
+
12
+ - **Two opt-in `PostToolUse` hooks** in `.claude/hooks/`. Like the two existing
13
+ `PreToolUse` guards, they ship as scripts and are **not** wired up by default;
14
+ opt in via your own gitignored `.claude/settings.local.json` (the block is in
15
+ [docs/README.md](docs/README.md)).
16
+ - `agent_rules_drift_check.py` runs `node scripts/agent-rules/generate.mjs
17
+ --check` after an edit to anything the generator actually reads
18
+ (`docs/SKILL.md`, `docs/kernels/README.md`, `resolve-advanced/README.md`,
19
+ and `generate.mjs` itself, which carries the DOMAINS manifest inline) or to
20
+ `AGENTS.md`, which it writes. It separates the generator's two exit-1
21
+ paths: real staleness always prints `N agent-rule file(s) are stale`, a
22
+ throw never does — and telling a session to regenerate when the generator
23
+ is the thing that crashed sends it in a circle. Watching outputs but not
24
+ inputs was the original bug: bumping the compound tool count in
25
+ `docs/SKILL.md` left five generated files stale and the hook said nothing.
26
+ **A new generator input has to be added to `SOURCE_PATHS` or the hook goes
27
+ silent on exactly the edit it exists for.**
28
+ - `run_matching_test.py` runs the matching `tests/test_<module>.py` after an
29
+ edit under `src/`, resolving the project venv (`venv/bin/python`) before
30
+ `python3` so `pytest` is importable rather than reporting a false failure.
31
+ A fast partial net, not coverage: 72 of the 126 modules under `src/` follow
32
+ the convention, densely in `src/utils/` and not at all for `src/server.py`,
33
+ `src/granular/common.py`, or `src/control_panel.py`. Silence means "no
34
+ matching test file", not "this edit is fine".
35
+ - **`drift-guard-reviewer` subagent** (`.claude/agents/`) — runs the drift-guard
36
+ test family plus the adjacent checks `npm-publish.yml` runs before every
37
+ publish, on demand and in its own context. It reports what is stale and which
38
+ regeneration command fixes it; it does not fix anything.
39
+ - **`release-check` skill** (`/release-check`) — a thin wrapper that reads and
40
+ follows [docs/process/release-process.md](docs/process/release-process.md)
41
+ from disk. It deliberately does not restate the checklist: a second copy can
42
+ drift from the original, which `CLAUDE.md` prohibits.
43
+
44
+ ### Changed
45
+
46
+ - `docs/README.md` documents all four hooks, all three subagents, and the new
47
+ skill in place, including the opt-in JSON block.
48
+
49
+ Contributed by [@Grimthereapper](https://github.com/Grimthereapper) in
50
+ [#149](https://github.com/samuelgursky/davinci-resolve-mcp/pull/149).
51
+
52
+ ## What's New in v2.97.3
53
+
54
+ **`source_end` no longer has the start timecode baked into it.** Reported by
55
+ [@TheUnlockr](https://github.com/TheUnlockr) in
56
+ [#147](https://github.com/samuelgursky/davinci-resolve-mcp/issues/147) against
57
+ Studio 21.0.4.5, and confirmed live here on 19.1.3.7.
58
+
59
+ ### Fixed
60
+
61
+ - **The two source frame fields did not share an origin.** `source_start` comes
62
+ from `GetSourceStartFrame`, which is file-relative. `source_end` came from
63
+ `round(GetSourceEndTime x fps)` — and both second-readers answer in the media's
64
+ TIMECODE space, so on any clip with a non-zero start TC the whole start
65
+ timecode landed in `source_end`. On the reporter's Canon MP4 starting at
66
+ 04:18:37;25 that produced `source_end` 468800 on a clip 10650 frames long. The
67
+ two fields could not be differenced, and anything sizing a pull from them —
68
+ `extract_source_frame_ranges`, `create_variant_from_ranges`, conform and
69
+ consolidation — was working from a number ~44x the clip's length.
70
+ - `source_end` is now the SPAN between the two second-readers, anchored on
71
+ `source_start`. The offset cancels in the difference, so the result is
72
+ file-relative under either convention and needs no timecode parsing, no `Start
73
+ TC` property read, and no drop-frame arithmetic. On media starting at
74
+ 00:00:00:00 the correction is exactly zero, so nothing moves there.
75
+ - **The `_seconds` pair is rebased to match.** `source_start_seconds` used to
76
+ disagree with `source_start / source_fps` on any camera clip, despite being
77
+ documented as seconds into the source file. All four `source_*` fields are now
78
+ file-relative and say so.
79
+
80
+ ### Why the existing suite could not catch it
81
+
82
+ The v2.93.0 work that introduced this was live-validated — but with synthetic
83
+ media, which starts at 00:00:00:00. That makes the offset exactly zero, so a
84
+ timecode-absolute reader is indistinguishable from a file-relative one in
85
+ precisely that setup. The new harness closes the gap by generating synthetic
86
+ media that *carries* a timecode.
87
+
88
+ ### Added
89
+
90
+ - `tests/live_source_timecode_validation.py` — a controlled pair: two clips from
91
+ the same generator, identical except that one carries `-timecode 04:18:37;25`,
92
+ both cut into a timeline at a different rate. Any difference in the reported
93
+ fields is attributable to the timecode and nothing else. 17/17 checks pass on
94
+ Studio 19.1.3.7.
95
+ - Six unit tests in `tests/test_source_frame_rate.py` pinning the reporter's
96
+ measured numbers, including that zero-TC media is untouched.
97
+
98
+ ### Documentation
99
+
100
+ - The `GetSourceStartFrame` api_truth entry claimed the v2.93.0 product was "a
101
+ SOURCE frame by construction". That holds only for media starting at
102
+ 00:00:00:00; the entry now says so, records the live confirmation, and carries
103
+ the rate-conversion caveat below.
104
+
105
+ ### Validation
106
+
107
+ - Live on **DaVinci Resolve Studio 19.1.3.7** (not the 21.0.4.5 of the report):
108
+ on the timecoded copy `GetSourceStartTime` read 15527.812 s where the
109
+ file-relative answer is 10.010 s while `GetSourceStartFrame` read 300 — the two
110
+ readers in different spaces on the same edit point. The pre-fix formula gave
111
+ 465461 on a 1799-frame clip. After the fix the timecoded copy reports exactly
112
+ what the zero-TC control reports, for every range tried.
113
+ - One caveat the harness deliberately does *not* assert: `source_end` is not the
114
+ `endFrame` you sent when the rates differ. The record duration quantizes to
115
+ whole timeline frames, so a 93-source-frame request at 29.97 into a 24 fps
116
+ timeline consumes ~92.4 and *both* copies report 392. That is rate conversion,
117
+ not a timecode error.
118
+
5
119
  ## What's New in v2.97.2
6
120
 
7
121
  **The panel's inventory snapshot survives a large project.** Follow-up to the
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.97.2-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-2.97.4-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-35%20(353%20full)-blue.svg)](#server-modes)
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.97.2-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-2.97.4-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-35%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.97.2 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
15
+ > 本翻译对应 v2.97.4 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
16
16
 
17
17
  一个 Model Context Protocol (MCP) 服务器,让 AI 助手通过官方脚本 API 控制 DaVinci Resolve Studio(达芬奇)。它提供完整的 API 覆盖,外加带护栏的工作流助手,涵盖剪辑、媒体池整理、渲染设置、审阅标记、调色、Fusion、Fairlight、项目生命周期任务、扩展开发,以及不碰源媒体的媒体分析。
18
18
 
package/docs/README.md CHANGED
@@ -69,23 +69,29 @@ skill routes, these two walk a whole job:
69
69
  - `resolve-tighten-recording` (`.claude/skills/resolve-tighten-recording/SKILL.md`)
70
70
  — **subtractive**: remove dead air from one long single-take recording.
71
71
 
72
- Two more sit outside the domain routing:
72
+ Three more sit outside the domain routing:
73
73
 
74
74
  - `house-style` (`.claude/skills/house-style/SKILL.md`) — accumulated editorial
75
75
  corrections, so the same note is not given twice. Claude-only; append to it
76
76
  when an editorial decision is corrected.
77
77
  - `resolve-session` (`.claude/skills/resolve-session/SKILL.md`) — `/resolve-session`
78
78
  connects, confirms edition and bridge, and reports project/timeline/pool state.
79
+ - `release-check` (`.claude/skills/release-check/SKILL.md`) — `/release-check`
80
+ walks a version bump using [docs/process/release-process.md](process/release-process.md)
81
+ as the sole source; the skill wraps that doc, it does not duplicate it.
79
82
 
80
83
  The offline half of every one is the advanced server; see
81
84
  [Advanced Server](../resolve-advanced/README.md).
82
85
 
83
86
  ## Claude Code Hooks and Subagents
84
87
 
85
- Two `PreToolUse` guards enforce rules `AGENTS.md` states in prose. They ship as
86
- scripts but are **not** wired up by default the repository does not enable
87
- hooks on your behalf. Opt in by adding the block below to your own
88
- `.claude/settings.local.json` (gitignored, so it stays yours):
88
+ Four hooks live in `.claude/hooks/` — two `PreToolUse` guards enforcing rules
89
+ `AGENTS.md` states in prose, and two `PostToolUse` checks that surface
90
+ engineering drift right after the edit that caused it instead of at the next
91
+ test run. All four ship as scripts but are **not** wired up by default — the
92
+ repository does not enable hooks on your behalf. Opt in by adding the block
93
+ below to your own `.claude/settings.local.json` (gitignored, so it stays
94
+ yours):
89
95
 
90
96
  ```json
91
97
  {
@@ -111,13 +117,30 @@ hooks on your behalf. Opt in by adding the block below to your own
111
117
  }
112
118
  ]
113
119
  }
120
+ ],
121
+ "PostToolUse": [
122
+ {
123
+ "matcher": "Write|Edit",
124
+ "hooks": [
125
+ {
126
+ "type": "command",
127
+ "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/agent_rules_drift_check.py",
128
+ "timeout": 30
129
+ },
130
+ {
131
+ "type": "command",
132
+ "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/run_matching_test.py",
133
+ "timeout": 90
134
+ }
135
+ ]
136
+ }
114
137
  ]
115
138
  }
116
139
  }
117
140
  ```
118
141
 
119
142
  Hooks are read at session start, so restart Claude Code after adding them. The
120
- two guards are:
143
+ four are:
121
144
 
122
145
  - `.claude/hooks/frame_verification_guard.py` — denies grade-applying actions on
123
146
  `timeline_item_color` until the session has actually looked at a
@@ -127,15 +150,36 @@ two guards are:
127
150
  - `.claude/hooks/source_media_guard.py` — denies shell commands that write,
128
151
  move, or delete source media outside a scratch root. Reads (`ffprobe`, and
129
152
  `ffmpeg` writing into scratch) pass.
130
-
131
- Two review subagents in `.claude/agents/` run in their own context so frame
132
- images stay out of the main session:
153
+ - `.claude/hooks/agent_rules_drift_check.py` — after an edit to anything
154
+ `generate.mjs` reads (`docs/SKILL.md`, `docs/kernels/README.md`,
155
+ `resolve-advanced/README.md`, and `generate.mjs` itself, which carries the
156
+ DOMAINS manifest inline) or to `AGENTS.md`, which it writes, runs
157
+ `node scripts/agent-rules/generate.mjs --check` and surfaces the result. It
158
+ distinguishes real drift from a generator that threw before it could look —
159
+ regenerating fixes the first and not the second. Informational only; never
160
+ blocks. Skips quietly if `node` isn't on `PATH`. **If `generate.mjs` grows a
161
+ new input, add it to `SOURCE_PATHS` in the hook** — an unwatched input is a
162
+ silent hook on exactly the edit it exists to catch.
163
+ - `.claude/hooks/run_matching_test.py` — after an edit to `src/<module>.py`,
164
+ runs the matching `tests/test_<module>.py` if one exists, using the project
165
+ venv (`venv/bin/python`) so `pytest` is actually importable. Informational
166
+ only; skips quietly if there's no matching test file or no working `pytest`.
167
+ A fast partial net, not coverage: 72 of the 126 modules under `src/` have a
168
+ matching test under this convention, densely in `src/utils/` and not at all
169
+ for `src/server.py` or `src/granular/common.py`. Silence means "no matching
170
+ test file", not "this edit is fine".
171
+
172
+ Three review subagents in `.claude/agents/` run in their own context so bulky
173
+ output (frame images, full test transcripts) stays out of the main session:
133
174
 
134
175
  - `cut-reviewer` — screens an assembled timeline from its frames and reports on
135
176
  pacing, shot order, continuity, and coverage gaps.
136
177
  - `grade-match-verifier` — measures shot match numerically from rendered frames
137
178
  against the project's R−B tolerance, and reports mask pixel counts so an empty
138
179
  skin mask cannot pass as a match.
180
+ - `drift-guard-reviewer` — runs the doc/generated-file drift-guard test family
181
+ (the same checks `npm-publish.yml` runs before every release) and reports
182
+ which files are stale relative to their source, without fixing them.
139
183
 
140
184
  ## Authoring References
141
185
 
@@ -402,8 +402,8 @@ values, or automation-hostile modal prompts.
402
402
 
403
403
  - **Object:** `TimelineItem`
404
404
  - **Signature:** `() -> int # source frame, counted in the MEDIA's frame rate`
405
- - **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.
406
- - **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.
405
+ - **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. But NOT necessarily at the source's ORIGIN: all 12 of those items were synthetic media starting at 00:00:00:00. The second-readers are TIMECODE-absolute, so on media with a non-zero start TC the product is a source-RATE frame counted from the camera's timecode zero, not from the head of the file (issue #147, corrected in v2.97.3 — see the handling note below). Use the SPAN between the two second-readers, never either one alone: the offset cancels in the difference. CONFIRMED LIVE here on Studio 19.1.3.7 (2026-08-17), independently of the 21.0.4.5 report, with a CONTROLLED PAIR: two ffmpeg-generated 29.97 clips identical except that one carries -timecode 04:18:37;25, both cut into a 24 fps timeline. On the timecoded copy GetSourceStartTime read 15527.812 s where the file-relative answer is 10.010 s, while GetSourceStartFrame read 300 — the two readers in different spaces on the same edit point. round(GetSourceEndTime x fps) gave 465461 on a 1799-frame clip. After the fix the timecoded copy reports exactly what the zero-TC control reports for every range tried (300..393, 0..100, 1000..1500). Harness: tests/live_source_timecode_validation.py. NOTE the harness does NOT assert source_end == the endFrame sent: when the rates differ the record duration quantizes to whole TIMELINE frames, so a 93-source-frame request consumes ~92.4 and BOTH copies report 392. That is rate conversion, not a timecode error.
406
+ - **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 read from GetSourceEndTime and stays EXCLUSIVE as every caller already assumed. CORRECTION (v2.97.3, issue #147): that release computed it as round(GetSourceEndTime x media_fps) and this entry called the result 'a SOURCE frame by construction'. That holds ONLY for media starting at 00:00:00:00. Both second-readers answer in the media's TIMECODE space, so on a camera clip carrying time-of-day or continuous TC the product has the whole start timecode baked into it: reported on a Canon MP4 starting at 04:18:37;25, source_end came back 468800 on a clip only 10650 frames long, ~44x its length, while source_start (GetSourceStartFrame) stayed file-relative — so the two fields no longer shared an origin and could not be differenced. The v2.93.0 live validation used synthetic media, which starts at 00:00:00:00, so the offset was zero and the error was invisible in exactly that setup. It is now the seconds SPAN between the two readers, anchored on source_start: the offset cancels in the difference, so the result is file-relative under either convention and needs no timecode parsing. All four source_* fields are file-relative, the _seconds pair included. 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.
407
407
  - **Tags:** timeline, audio, wav, frame-rate, mixed-fps, silent-failure, readback
408
408
 
409
409
  ### Studio-gated calls on the free edition raise a modal that blocks LATER calls
package/install.py CHANGED
@@ -37,7 +37,7 @@ from src.utils.update_check import (
37
37
 
38
38
  # ─── Version ──────────────────────────────────────────────────────────────────
39
39
 
40
- VERSION = "2.97.2"
40
+ VERSION = "2.97.4"
41
41
  # Only hard floor: mcp[cli] requires Python 3.10+. There is no upper bound —
42
42
  # Resolve's scripting bridge loads into newer interpreters on recent builds
43
43
  # (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.97.2",
3
+ "version": "2.97.4",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -87,7 +87,7 @@ if not logging.getLogger().handlers:
87
87
  handlers=[logging.StreamHandler()],
88
88
  )
89
89
 
90
- VERSION = "2.97.2"
90
+ VERSION = "2.97.4"
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.97.2"
14
+ VERSION = "2.97.4"
15
15
 
16
16
  import base64
17
17
  import os
@@ -3138,7 +3138,36 @@ def _timeline_item_source_end_frame(item):
3138
3138
  return None
3139
3139
 
3140
3140
 
3141
- def _timeline_item_source_end_exclusive(item, source_start, duration, source_fps):
3141
+ def _timeline_item_source_time_offset(item, source_start, source_fps, source_start_origin):
3142
+ """Seconds between the second-readers' origin and the start of the file.
3143
+
3144
+ `GetSourceStartTime`/`GetSourceEndTime` answer in the media's TIMECODE space,
3145
+ so on any clip with a non-zero start TC they carry that offset — a Canon MP4
3146
+ starting at 04:18:37;25 reads 15639.187 s at source frame 3637, not the
3147
+ 121.355 s that frame is into the file. `GetSourceStartFrame`, meanwhile, is
3148
+ file-relative. Mixing the two spaces is what made `source_end` exceed the
3149
+ clip's own length by the whole start timecode (#147).
3150
+
3151
+ The offset is recoverable from the item alone, with no timecode parsing and
3152
+ no drop-frame arithmetic: the same edit point read both ways differs by
3153
+ exactly the offset. Returns None when it cannot be established, and 0.0 on
3154
+ media starting at 00:00:00:00 — where every caller below is then a no-op,
3155
+ which is why zero-TC behavior does not move.
3156
+ """
3157
+ if source_start is None or not source_fps:
3158
+ return None
3159
+ # GetLeftOffset counts an audio item in TIMELINE frames, so it is not the
3160
+ # same space as the second-readers and cannot calibrate them.
3161
+ if source_start_origin != "GetSourceStartFrame":
3162
+ return None
3163
+ start_seconds = _timeline_item_source_time(item, "GetSourceStartTime")
3164
+ if start_seconds is None:
3165
+ return None
3166
+ return start_seconds - (source_start / source_fps)
3167
+
3168
+
3169
+ def _timeline_item_source_end_exclusive(item, source_start, duration, source_fps,
3170
+ source_start_origin=None):
3142
3171
  """The EXCLUSIVE source-space end frame — the contract every caller assumes.
3143
3172
 
3144
3173
  `source_end` has always been exclusive (`src_end_excl` at the append site,
@@ -3161,23 +3190,47 @@ def _timeline_item_source_end_exclusive(item, source_start, duration, source_fps
3161
3190
  the two regimes (see above), so building on it would mean branching on a
3162
3191
  rate comparison this function would first have to reconstruct.
3163
3192
 
3193
+ That product is only a FILE-relative frame when the media starts at
3194
+ 00:00:00:00, though. `GetSourceEndTime` answers in the media's timecode
3195
+ space, so on camera footage carrying time-of-day or continuous TC it bakes
3196
+ the start timecode into the number: measured on a Canon MP4 starting at
3197
+ 04:18:37;25, it produced source_end 468800 on a clip only 10650 frames long
3198
+ (#147). `source_start` is file-relative, so the two fields stopped sharing an
3199
+ origin and could no longer be differenced.
3200
+
3201
+ The span between the two second-readers is offset-free whichever space they
3202
+ are in, so anchoring that span on the file-relative `source_start` is correct
3203
+ under both conventions and needs no timecode parsing. On zero-TC media it
3204
+ reproduces the plain product exactly, so nothing moves there.
3205
+
3164
3206
  Falls back to the historical `source_start + duration` when the second-reader
3165
3207
  or the rate is unreadable — same value as before, so an older build loses the
3166
3208
  correction rather than the field.
3167
3209
  """
3168
3210
  end_seconds = _timeline_item_source_time(item, "GetSourceEndTime")
3169
3211
  if end_seconds is not None and source_fps:
3170
- end_frame = int(round(end_seconds * source_fps))
3171
- # A source end at or before the start means the readers disagree about
3172
- # this item; the derived value is the safer answer than a negative span.
3173
- if source_start is None or end_frame > source_start:
3174
- return end_frame
3212
+ offset = _timeline_item_source_time_offset(
3213
+ item, source_start, source_fps, source_start_origin)
3214
+ if offset is not None:
3215
+ span = int(round((end_seconds - offset) * source_fps)) - source_start
3216
+ # A non-positive span means the readers disagree about this item;
3217
+ # the derived value is safer than an inverted range.
3218
+ if span > 0:
3219
+ return source_start + span
3220
+ else:
3221
+ # No way to calibrate the origin: pre-#147 behavior, which is exact
3222
+ # on media starting at 00:00:00:00 and on every build old enough to
3223
+ # lack GetSourceStartTime that we have measured.
3224
+ end_frame = int(round(end_seconds * source_fps))
3225
+ if source_start is None or end_frame > source_start:
3226
+ return end_frame
3175
3227
  if source_start is not None and duration is not None:
3176
3228
  return source_start + duration
3177
3229
  return None
3178
3230
 
3179
3231
 
3180
- def _timeline_item_source_seconds(item, source_start, source_end, source_fps):
3232
+ def _timeline_item_source_seconds(item, source_start, source_end, source_fps,
3233
+ source_start_origin=None):
3181
3234
  """(start_seconds, end_seconds) into the source file, or None each.
3182
3235
 
3183
3236
  Prefers Resolve's second-readers, then a source-space frame divided by the
@@ -3185,14 +3238,26 @@ def _timeline_item_source_seconds(item, source_start, source_end, source_fps):
3185
3238
  is ``source_start + timeline_duration``, so on a 24 fps WAV in a 29.97 fps
3186
3239
  timeline it overstates the clip's span by 25% (18.1 s reported for a
3187
3240
  14.5 s clip). An unknown end reads as unknown.
3241
+
3242
+ Both readers answer in the media's TIMECODE space, so they are rebased onto
3243
+ the file-relative origin the frame fields use (#147) — otherwise
3244
+ ``source_start_seconds`` disagrees with ``source_start / source_fps`` on any
3245
+ camera clip, and "seconds into the source file" is not what the field holds.
3246
+ The correction is exactly zero on media starting at 00:00:00:00.
3188
3247
  """
3248
+ offset = _timeline_item_source_time_offset(
3249
+ item, source_start, source_fps, source_start_origin)
3189
3250
  start_seconds = _timeline_item_source_time(item, "GetSourceStartTime")
3190
3251
  if start_seconds is None:
3191
3252
  start_seconds = _source_frames_to_seconds(source_start, source_fps)
3253
+ elif offset is not None:
3254
+ start_seconds = round(start_seconds - offset, 3)
3192
3255
  end_seconds = _timeline_item_source_time(item, "GetSourceEndTime")
3193
3256
  if end_seconds is None:
3194
3257
  end_seconds = _source_frames_to_seconds(
3195
3258
  _timeline_item_source_end_frame(item), source_fps)
3259
+ elif offset is not None:
3260
+ end_seconds = round(end_seconds - offset, 3)
3196
3261
  return start_seconds, end_seconds
3197
3262
 
3198
3263
 
@@ -3221,7 +3286,7 @@ def _timeline_item_summary(item, track_info=None, *, media_pool_item=None,
3221
3286
  # of whichever reader produced source_start, so the audio caveat that blanks
3222
3287
  # source_fps must not blank the end as well.
3223
3288
  source_end = _timeline_item_source_end_exclusive(
3224
- item, source_start, duration, media_fps)
3289
+ item, source_start, duration, media_fps, source_start_origin)
3225
3290
  source_fps = media_fps
3226
3291
  if source_start_origin == "GetLeftOffset" and (track_info or (None,))[0] == "audio":
3227
3292
  # GetLeftOffset counts an audio item in TIMELINE frames, so pairing it
@@ -3229,7 +3294,7 @@ def _timeline_item_summary(item, track_info=None, *, media_pool_item=None,
3229
3294
  # the frame and leave the rate unknown rather than convert it wrong.
3230
3295
  source_fps = None
3231
3296
  source_start_seconds, source_end_seconds = _timeline_item_source_seconds(
3232
- item, source_start, source_end, source_fps)
3297
+ item, source_start, source_end, source_fps, source_start_origin)
3233
3298
  summary = {
3234
3299
  "timeline_item_id": _safe_timeline_item_id(item),
3235
3300
  "name": _safe_timeline_item_name(item),
@@ -3238,10 +3303,13 @@ def _timeline_item_summary(item, track_info=None, *, media_pool_item=None,
3238
3303
  "start": start,
3239
3304
  "end": end,
3240
3305
  "duration": duration,
3306
+ # All four source_* fields below are FILE-relative: frame 0 / 0.0 s is the
3307
+ # first frame of the media, whatever timecode the camera stamped on it.
3241
3308
  "source_start": source_start,
3242
- # EXCLUSIVE source frame. Read from source space via GetSourceEndTime;
3243
- # falls back to source_start + TIMELINE duration only when that reader or
3244
- # the media rate is unavailable, which is the old unit-mixed value.
3309
+ # EXCLUSIVE source frame. The span between Resolve's second-readers,
3310
+ # anchored on source_start so a non-zero start timecode cannot leak in;
3311
+ # falls back to source_start + TIMELINE duration only when those readers
3312
+ # or the media rate are unavailable, which is the old unit-mixed value.
3245
3313
  "source_end": source_end,
3246
3314
  "source_fps": source_fps,
3247
3315
  "source_start_seconds": source_start_seconds,
@@ -22275,10 +22343,13 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
22275
22343
  freezes the PROJECT's rate at import, so it differs from the timeline whenever
22276
22344
  the project moved afterwards, and dividing by the timeline rate is then wrong by
22277
22345
  minutes. source_fps is null when the rate could not be read; treat the frames as
22278
- unitless then, do not assume the timeline's. source_end comes from
22279
- GetSourceEndTime x source_fps, so it is a source frame even when the rates
22280
- differ; it falls back to source_start + TIMELINE duration (unit-mixed) only when
22281
- that reader or the rate is unavailable.
22346
+ unitless then, do not assume the timeline's. All four source_* fields are
22347
+ FILE-relative frame 0 is the head of the media, whatever timecode the camera
22348
+ stamped on it. source_end is the span between Resolve's source second-readers
22349
+ anchored on source_start, so it is a source frame even when the rates differ and
22350
+ even on media with a non-zero start TC (those readers are timecode-absolute; the
22351
+ offset cancels in the span). It falls back to source_start + TIMELINE duration
22352
+ (unit-mixed) only when those readers or the rate are unavailable.
22282
22353
  detect_gaps_overlaps(track_types?, min_gap?) -> {gaps, overlaps}
22283
22354
  source_range_report(handles?, merge?) -> {ranges, occurrences}
22284
22355
  export_timeline_checked(path, format?|type?, subtype?, require_temp_path?, dry_run?, background?) -> {success, path, size | job_id}
@@ -913,8 +913,33 @@ API_TRUTH: List[Dict[str, Any]] = [
913
913
  "regimes is GetSourceEndTime x media_fps, which was exact "
914
914
  "on 12 of 12 valid items (30.633 s x 24 = 735.19 -> 735; "
915
915
  "24.524 s x 29.97 = 734.98 -> 735) — seconds carry no "
916
- "frame-rate assumption, so the product is in source space "
917
- "by construction.",
916
+ "frame-rate assumption, so the product is in source space. "
917
+ "But NOT necessarily at the source's ORIGIN: all 12 of those "
918
+ "items were synthetic media starting at 00:00:00:00. The "
919
+ "second-readers are TIMECODE-absolute, so on media with a "
920
+ "non-zero start TC the product is a source-RATE frame "
921
+ "counted from the camera's timecode zero, not from the head "
922
+ "of the file (issue #147, corrected in v2.97.3 — see the "
923
+ "handling note below). Use the SPAN between the two "
924
+ "second-readers, never either one alone: the offset cancels "
925
+ "in the difference. CONFIRMED LIVE here on Studio 19.1.3.7 "
926
+ "(2026-08-17), independently of the 21.0.4.5 report, with a "
927
+ "CONTROLLED PAIR: two ffmpeg-generated 29.97 clips identical "
928
+ "except that one carries -timecode 04:18:37;25, both cut "
929
+ "into a 24 fps timeline. On the timecoded copy "
930
+ "GetSourceStartTime read 15527.812 s where the file-relative "
931
+ "answer is 10.010 s, while GetSourceStartFrame read 300 — "
932
+ "the two readers in different spaces on the same edit point. "
933
+ "round(GetSourceEndTime x fps) gave 465461 on a 1799-frame "
934
+ "clip. After the fix the timecoded copy reports exactly what "
935
+ "the zero-TC control reports for every range tried "
936
+ "(300..393, 0..100, 1000..1500). Harness: "
937
+ "tests/live_source_timecode_validation.py. NOTE the harness "
938
+ "does NOT assert source_end == the endFrame sent: when the "
939
+ "rates differ the record duration quantizes to whole "
940
+ "TIMELINE frames, so a 93-source-frame request consumes "
941
+ "~92.4 and BOTH copies report 392. That is rate conversion, "
942
+ "not a timecode error.",
918
943
  "recommended": "Convert an audio item's source frames with the MEDIA's rate, "
919
944
  "never the timeline's: seconds = source_start / media_fps. "
920
945
  "READ media_fps from the media-pool item's 'FPS' clip property "
@@ -936,9 +961,29 @@ API_TRUTH: List[Dict[str, Any]] = [
936
961
  "reports the rate as unknown rather than converting a "
937
962
  "timeline-frame value at the media rate. source_end is "
938
963
  "no longer source_start + TIMELINE duration: as of "
939
- "v2.93.0 it is round(GetSourceEndTime x media_fps), "
940
- "which is a SOURCE frame by construction and stays "
941
- "EXCLUSIVE as every caller already assumed. Measured "
964
+ "v2.93.0 it is read from GetSourceEndTime and stays "
965
+ "EXCLUSIVE as every caller already assumed. CORRECTION "
966
+ "(v2.97.3, issue #147): that release computed it as "
967
+ "round(GetSourceEndTime x media_fps) and this entry "
968
+ "called the result 'a SOURCE frame by construction'. "
969
+ "That holds ONLY for media starting at 00:00:00:00. "
970
+ "Both second-readers answer in the media's TIMECODE "
971
+ "space, so on a camera clip carrying time-of-day or "
972
+ "continuous TC the product has the whole start "
973
+ "timecode baked into it: reported on a Canon MP4 "
974
+ "starting at 04:18:37;25, source_end came back 468800 "
975
+ "on a clip only 10650 frames long, ~44x its length, "
976
+ "while source_start (GetSourceStartFrame) stayed "
977
+ "file-relative — so the two fields no longer shared an "
978
+ "origin and could not be differenced. The v2.93.0 live "
979
+ "validation used synthetic media, which starts at "
980
+ "00:00:00:00, so the offset was zero and the error was "
981
+ "invisible in exactly that setup. It is now the seconds "
982
+ "SPAN between the two readers, anchored on source_start: "
983
+ "the offset cancels in the difference, so the result is "
984
+ "file-relative under either convention and needs no "
985
+ "timecode parsing. All four source_* fields are "
986
+ "file-relative, the _seconds pair included. Measured "
942
987
  "live on 19.1.3.7 over 8 items in both regimes, it "
943
988
  "equals the endFrame actually sent every time, and it "
944
989
  "reproduces the old value exactly wherever the old value "
@@ -954,6 +999,7 @@ API_TRUTH: List[Dict[str, Any]] = [
954
999
  "submit": "bug",
955
1000
  "mitigation": ["_media_item_source_fps", "_source_frames_to_seconds",
956
1001
  "_timeline_item_source_seconds",
1002
+ "_timeline_item_source_time_offset",
957
1003
  "_timeline_item_source_end_exclusive"],
958
1004
  },
959
1005
  {