Skip to content

Commit 1c0148c

Browse files
committed
fix(timemap): reverse and speed ramps both work; decoder reported a reverse as speed 0 — release v2.95.3
The retime api_truth entry carried an explicit warning: "UNTESTED as import routes: reverse (negative time_scalar / reverse M2) and varying-speed maps — do not extrapolate the constant-speed result to them." Both are now measured on Studio 21.0.4.5 and both work; the warning is closed. REVERSE lands through both import routes. OTIO with time_scalar -1 placed a clip reading GetSourceStartFrame 95 -> GetSourceEndFrame 46; EDL with M2 -24.0 read 48 -> 0. So the API DOES expose direction — a reversed clip reports source start GREATER than source end, a negative span. VARYING-SPEED RAMPS cannot be expressed in OTIO (LinearTimeWarp is one time_scalar, constant by construction) but can be authored offline and survive. A Sm2TimeMap built with buildTimemap carrying 0-2s record at 0.5x and 2-4s at 2.0x was patched into a clip's MediaTimemapBA, imported, and re-exported by Resolve with the segments UNCHANGED. Independent API-side confirmation: the clip read source 0..120 over a 96-frame record, and 2s@0.5x (24 source frames) + 2s@2.0x (96) is exactly 120. Two decoder bugs, both exposed by the first reversed map media-timemap.js ever saw: - Fixed-offset keyframe reads. Each point omits whichever of recordSec/sourceSec is zero (protobuf default-omission), so readDoubleLE(1)/readDoubleLE(10) threw "offset out of range" on every reversed map. Forward maps decoded only because both values happened to be non-zero. - A hardcoded (0,0) origin. A reversed clip starts at the far end of the source and Resolve encodes that starting offset as a TOP-LEVEL protobuf field 2 double. Assuming (0,0) made a reverse decode as SPEED 0 — a plausible wrong number, worse than a crash. It now decodes as -1. Four tests pin the reverse shape, the omitted-value points, the negative-speed result, and that forward maps are unaffected. Also records a trap that cost real time: buildTimemap returns a BUFFER, and writing it into XML without .toString('hex') embeds mojibake. The failure is silent — the clip imports cleanly and reads 0..0, indistinguishable from the degenerate-map signature this ledger describes for xmeml imports, so it reads as "Resolve rejected the retime" when it is a caller bug. Python suite 2633 passed / 1 skipped; Node suite 818, 0 fail.
1 parent 0c692f2 commit 1c0148c

11 files changed

Lines changed: 203 additions & 20 deletions

File tree

CHANGELOG.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,51 @@
22

33
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.
44

5+
## What's New in v2.95.3
6+
7+
Closes the retime entry's explicit `UNTESTED` warning: **reverse and
8+
variable-speed ramps both work**, and fixes two decoder bugs found proving it.
9+
10+
### Measured — reverse lands through both import routes
11+
12+
On Studio 21.0.4.5. OTIO with a negative `time_scalar` (-1) placed a clip reading
13+
`GetSourceStartFrame` 95 → `GetSourceEndFrame` 46; EDL with a negative `M2` rate
14+
(-24.0 at 24fps) read 48 → 0. **The API does expose direction** — a reversed clip
15+
reports source start GREATER than source end, i.e. a negative span.
16+
17+
### Measured — variable-speed ramps survive Resolve intact
18+
19+
They cannot be expressed in OTIO (`LinearTimeWarp` is a single `time_scalar`,
20+
constant by construction), but they can be authored offline. A `Sm2TimeMap` built
21+
with `media-timemap.buildTimemap` carrying two segments — 0–2s record at 0.5×,
22+
2–4s at 2.0× — was patched into a clip's `MediaTimemapBA`, imported, and
23+
**re-exported by Resolve with the segments unchanged**. Confirmed independently
24+
from the API side: the clip read source `0..120` over a 96-frame record, and
25+
2s@0.5× (24 source frames) + 2s@2.0× (96) is exactly 120.
26+
27+
### Fixed — the timemap decoder reported a reverse as speed 0
28+
29+
Two real bugs in `media-timemap.js`, both exposed by the first reversed map it
30+
ever saw:
31+
32+
- **Fixed-offset keyframe reads.** Each point omits whichever of
33+
`recordSec`/`sourceSec` is zero (protobuf default-omission), so
34+
`readDoubleLE(1)`/`readDoubleLE(10)` threw *"offset out of range"* on every
35+
reversed map. Forward maps only decoded because both values happened to be
36+
non-zero.
37+
- **A hardcoded (0,0) origin.** A reversed clip starts at the far end of the
38+
source, and Resolve encodes that starting offset as a **top-level protobuf
39+
field 2** double. Assuming (0,0) made a reverse decode as **speed 0** — a
40+
plausible wrong number, which is worse than a crash. It now decodes as −1.
41+
42+
### Trap worth naming
43+
44+
`buildTimemap` returns a **Buffer**. Writing it into the XML without
45+
`.toString('hex')` embeds mojibake, and the failure is silent: the clip imports
46+
cleanly and reads `0..0` — indistinguishable from the degenerate-map signature
47+
the ledger describes for xmeml imports, so it looks like Resolve rejected the
48+
retime when it is a caller bug. It cost real time here before being caught.
49+
550
## What's New in v2.95.2
651

752
Adds the live round-trip harness the offline `.drp` tier never had — the absence

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
English | [简体中文](README.zh-CN.md)
44

5-
[![Version](https://img.shields.io/badge/version-2.95.2-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5+
[![Version](https://img.shields.io/badge/version-2.95.3-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
66
[![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
77
[![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
88
[![Tools](https://img.shields.io/badge/MCP%20Tools-34%20(353%20full)-blue.svg)](#server-modes)

README.zh-CN.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
[English](README.md) | 简体中文
44

5-
[![Version](https://img.shields.io/badge/version-2.95.2-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5+
[![Version](https://img.shields.io/badge/version-2.95.3-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
66
[![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
77
[![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
88
[![Tools](https://img.shields.io/badge/MCP%20Tools-34%20(353%20full)-blue.svg)](#服务器模式)
@@ -12,7 +12,7 @@
1212
[![Python](https://img.shields.io/badge/python-3.10+-green.svg)](https://www.python.org/downloads/)
1313
[![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://opensource.org/licenses/MIT)
1414

15-
> 本翻译对应 v2.95.2 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
15+
> 本翻译对应 v2.95.3 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
1616
1717
一个 Model Context Protocol (MCP) 服务器,让 AI 助手通过官方脚本 API 控制 DaVinci Resolve Studio(达芬奇)。它提供完整的 API 覆盖,外加带护栏的工作流助手,涵盖剪辑、媒体池整理、渲染设置、审阅标记、调色、Fusion、Fairlight、项目生命周期任务、扩展开发,以及不碰源媒体的媒体分析。
1818

docs/reference/api-limitations.md

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

install.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636

3737
# ─── Version ──────────────────────────────────────────────────────────────────
3838

39-
VERSION = "2.95.2"
39+
VERSION = "2.95.3"
4040
# Only hard floor: mcp[cli] requires Python 3.10+. There is no upper bound —
4141
# Resolve's scripting bridge loads into newer interpreters on recent builds
4242
# (Python 3.14 verified against Resolve Studio 20.3.2). Older Resolve builds

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "davinci-resolve-mcp",
3-
"version": "2.95.2",
3+
"version": "2.95.3",
44
"description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
55
"license": "MIT",
66
"author": "Samuel Gursky <samgursky@gmail.com>",

resolve-advanced/vendor/drp-format/__tests__/media-timemap.test.js

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,3 +117,59 @@ test('identityTimemap builds a [02][end,0,end,0,end] map', () => {
117117
assert.strictEqual(b.seconds.length, 5);
118118
assert.strictEqual(Math.round(b.seconds[0] * (30000 / 1001)), 4575);
119119
});
120+
121+
// --- Reverse maps: the origin is NOT always (0,0) ---------------------------
122+
// Captured from a clip reversed via EDL M2 / OTIO negative time_scalar and
123+
// exported by DaVinci Resolve Studio 21.0.4.5. Two things here used to break:
124+
// 1. Each keyframe point omits whichever of recordSec/sourceSec is 0 —
125+
// protobuf default-omission — so the old fixed-offset reader
126+
// (readDoubleLE(1) / readDoubleLE(10)) threw "offset out of range".
127+
// 2. The starting source offset is a TOP-LEVEL field 2 double. Assuming a
128+
// (0,0) origin made a reverse decode as speed 0 — a plausible wrong
129+
// number rather than an error, which is the worse failure.
130+
const REVERSED_KEYFRAMES_BA = '800a09115655555555d517400a09095655555555d51740';
131+
132+
function reversedMapHex() {
133+
const { encodeKeyedDict } = require('../keyed-dict');
134+
const T_DOUBLE = 6; const T_STRING = 10; const T_BYTES = 12;
135+
return encodeKeyedDict({
136+
hdr: 1,
137+
entries: [
138+
{ key: 'YMin', type: T_DOUBLE, subType: 0, value: -1 },
139+
{ key: 'YMax', type: T_DOUBLE, subType: 0, value: -1 },
140+
{ key: 'XMax', type: T_DOUBLE, subType: 0, value: 5.958333333333334 },
141+
{ key: 'UniqueId', type: T_STRING, subType: 0, value: '4dbe3e42-ab1e-4b93-8118-67fdc376c962' },
142+
{ key: 'LastValidYOffset', type: T_DOUBLE, subType: 0, value: 5.958333333333333 },
143+
{ key: 'KeyframesBA', type: T_BYTES, subType: 0, value: REVERSED_KEYFRAMES_BA },
144+
{ key: 'DbType', type: T_STRING, subType: 0, value: 'Sm2TimeMap' },
145+
],
146+
}).toString('hex');
147+
}
148+
149+
test('decodeTimemap: a reversed map decodes without throwing', () => {
150+
assert.doesNotThrow(() => decodeTimemap(reversedMapHex()));
151+
});
152+
153+
test('decodeTimemap: a reversed map reports NEGATIVE speed, not 0', () => {
154+
const d = decodeTimemap(reversedMapHex());
155+
assert.equal(d.segments.length, 1);
156+
assert.equal(d.segments[0].speed, -1);
157+
assert.ok(d.segments[0].speed < 0, 'reverse must not decode as speed 0');
158+
});
159+
160+
test('_decodeKeyframePoint: a point may omit either value (protobuf default 0)', () => {
161+
// sourceSec-only and recordSec-only points both appear in one real reversed map.
162+
const d = decodeTimemap(reversedMapHex());
163+
assert.equal(d.recordDurationSec, 5.958333333333334);
164+
assert.equal(d.sourceDurationSec, 5.958333333333333);
165+
});
166+
167+
test('forward maps are unaffected by the origin fix', () => {
168+
const fwd = buildTimemap({
169+
keyframes: [{ recordSec: 2, sourceSec: 1 }, { recordSec: 4, sourceSec: 5 }],
170+
sourceDurationSec: 6, recordDurationSec: 4, uniqueId: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
171+
});
172+
const d = decodeTimemap(fwd.toString('hex'));
173+
assert.equal(d.variable, true);
174+
assert.deepEqual(d.segments.map((s) => s.speed), [0.5, 2]);
175+
});

resolve-advanced/vendor/drp-format/media-timemap.js

Lines changed: 61 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -41,17 +41,69 @@ function _isKeyedForm(b) {
4141
* (fixed64 LE doubles). The map starts at the implicit (0,0); constant speed has one keyframe,
4242
* a variable-speed ramp has one keyframe per added speed point.
4343
*/
44+
/**
45+
* One keyframe point: an inner message of field 1 (recordSec) and field 2
46+
* (sourceSec), both wire type 1 (64-bit double) — tags 0x09 and 0x11.
47+
*
48+
* Both are OPTIONAL. Protobuf omits a field whose value is the default (0), so
49+
* a point can legitimately carry only one of the two, and fixed offsets do not
50+
* work. This is not theoretical: a REVERSED clip exported by Resolve 21.0.4.5
51+
* encodes its points as `0a 09 11 <double>` (sourceSec only) and
52+
* `0a 09 09 <double>` (recordSec only), and the previous fixed-offset reader
53+
* — `readDoubleLE(1)` / `readDoubleLE(10)` — threw
54+
* "offset out of range … Received 10" on every reversed map. Forward maps only
55+
* decoded because both values happened to be non-zero.
56+
*/
57+
function _decodeKeyframePoint(buf) {
58+
let recordSec = 0;
59+
let sourceSec = 0;
60+
let i = 0;
61+
while (i < buf.length) {
62+
const tag = buf[i];
63+
if (tag === 0x09 && i + 9 <= buf.length) { recordSec = buf.readDoubleLE(i + 1); i += 9; }
64+
else if (tag === 0x11 && i + 9 <= buf.length) { sourceSec = buf.readDoubleLE(i + 1); i += 9; }
65+
else break; // unknown tag or truncated — stop rather than misread
66+
}
67+
return { recordSec, sourceSec };
68+
}
69+
70+
/**
71+
* Keyframe points plus the map's ORIGIN.
72+
*
73+
* The origin is not always (0,0). A reversed clip starts at the far end of the
74+
* source and walks backwards, and Resolve encodes that starting source offset as
75+
* a TOP-LEVEL field 2 double alongside the keyframe messages. Measured on a
76+
* reversed clip exported by Studio 21.0.4.5:
77+
*
78+
* 80 0a 09 field 160 = 9
79+
* 11 5655555555d51740 field 2 = 5.9583 <- origin sourceSec
80+
* 0a 09 09 5655555555d51740 field 1 = { recordSec: 5.9583 }
81+
*
82+
* Reading that as an implicit (0,0) origin yields a segment from (0,0) to
83+
* (5.9583, 0) — slope 0 — so a reverse decodes as "speed 0", a plausible-looking
84+
* wrong answer rather than an error. With the origin it is (0, 5.9583) to
85+
* (5.9583, 0): slope -1, a reverse.
86+
*/
4487
function _decodeKeyframes(hex) {
45-
if (hex == null) return [];
88+
if (hex == null) return { origin: { recordSec: 0, sourceSec: 0 }, keyframes: [] };
4689
const fields = decodeProtobuf(hex);
47-
return fields
48-
.filter((f) => f.field === 1 && f.wire === 2)
49-
.map((f) => ({ recordSec: f.value.readDoubleLE(1), sourceSec: f.value.readDoubleLE(10) }));
90+
const originField = fields.find((f) => f.field === 2 && f.wire === 1);
91+
const originSource = originField
92+
? (Buffer.isBuffer(originField.value)
93+
? originField.value.readDoubleLE(0)
94+
: Number(originField.value))
95+
: 0;
96+
return {
97+
origin: { recordSec: 0, sourceSec: Number.isFinite(originSource) ? originSource : 0 },
98+
keyframes: fields
99+
.filter((f) => f.field === 1 && f.wire === 2)
100+
.map((f) => _decodeKeyframePoint(f.value)),
101+
};
50102
}
51103

52-
/** Per-segment speeds from the keyframe points (slope Δsource/Δrecord); starts at (0,0). */
53-
function _segments(keyframes) {
54-
const pts = [{ recordSec: 0, sourceSec: 0 }, ...keyframes];
104+
/** Per-segment speeds from the keyframe points (slope Δsource/Δrecord). */
105+
function _segments(keyframes, origin = { recordSec: 0, sourceSec: 0 }) {
106+
const pts = [origin, ...keyframes];
55107
const segs = [];
56108
for (let i = 1; i < pts.length; i++) {
57109
const dr = pts[i].recordSec - pts[i - 1].recordSec;
@@ -68,8 +120,8 @@ function decodeTimemap(input) {
68120
const get = (k) => { const e = entries.find((x) => x.key === k); return e ? e.value : undefined; };
69121
const recordDurationSec = get('XMax');
70122
const sourceDurationSec = get('LastValidYOffset');
71-
const keyframes = _decodeKeyframes(get('KeyframesBA'));
72-
const segments = _segments(keyframes);
123+
const { origin, keyframes } = _decodeKeyframes(get('KeyframesBA'));
124+
const segments = _segments(keyframes, origin);
73125
// The EXACT speed lives in the keyframe ratios (source/record per segment); XMax and
74126
// LastValidYOffset are frame-quantized. `speed` is the first segment's (whole clip if 1 kf);
75127
// `segments` carries the full variable-speed ramp.

src/granular/common.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@
8787
handlers=[logging.StreamHandler()],
8888
)
8989

90-
VERSION = "2.95.2"
90+
VERSION = "2.95.3"
9191
logger = logging.getLogger("davinci-resolve-mcp")
9292
logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
9393
logger.info(f"Detected platform: {get_platform()}")

src/server.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
python src/server.py --full # Start the 353-tool granular server instead
1212
"""
1313

14-
VERSION = "2.95.2"
14+
VERSION = "2.95.3"
1515

1616
import base64
1717
import os

0 commit comments

Comments
 (0)