davinci-resolve-mcp 2.95.1 → 2.95.3

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,98 @@
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.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
+
50
+ ## What's New in v2.95.2
51
+
52
+ Adds the live round-trip harness the offline `.drp` tier never had — the absence
53
+ of which is why three unbacked "verified live" claims survived in its README, one
54
+ reaching a shipped `api_truth` recommendation before being caught.
55
+
56
+ ### Added — `tests/live_drp_roundtrip_verification.py`
57
+
58
+ Authors a `.drp` per primitive, imports it into a running Resolve, asserts intent
59
+ against structural readback, **and exports the composited frame to assert the
60
+ item is actually visible on screen.**
61
+
62
+ That last assertion is the whole point. `place_fusion_title` satisfies every
63
+ structural check — right track, frame, duration, `PrettyType`, and correctly
64
+ encoded text — while rendering nothing, so a harness that only diffed
65
+ `GetStart()`/`GetDuration()` would have called it green. Visibility is measured
66
+ with `Project.ExportCurrentFrameAsStill` plus a luma pass: an inert item yields
67
+ `max=0` across the entire frame, a real one does not (the live control returns
68
+ `max=255` with ~16k bright pixels).
69
+
70
+ **Known-broken cases are declared, not skipped.** `place_fusion_title` is
71
+ recorded as `KNOWN BROKEN`; if it starts working the harness reports `UNEXPECTED
72
+ PASS` and fails, forcing the docs that describe it to be updated.
73
+
74
+ Current state on Studio 21.0.4.5: **7 passed, 1 known-broken, 0 needing
75
+ attention** — media placement, blade, trim, cross-track move, and generator
76
+ visibility all verified end to end.
77
+
78
+ ### Fixed — the `RESOLVE_VERIFY=1` gate was unrunnable
79
+
80
+ It ran a single test that threw `TODO — implement once fixtures land`, so the
81
+ flag reported a failing suite and everyone learned to ignore it. A gate nobody
82
+ can run green is worse than no gate. It is now a documented skip pointing at the
83
+ harness above, and `RESOLVE_VERIFY=1 npm test` is clean.
84
+
85
+ ### Changed — release process states the rule
86
+
87
+ "The file round-trips" and "Resolve honours it" are different claims; only a live
88
+ import establishes the second. Changes to the offline `.drp` tier must run the
89
+ harness, and a doc may not say "verified live" unless a runnable command produced
90
+ that result.
91
+
92
+ The harness is also robust to `DeleteProject`'s session lock: projects opened in
93
+ a session cannot be deleted until Resolve restarts, so it steps off the current
94
+ project, retries, falls back to run-unique names rather than colliding, and
95
+ reports what it could not remove instead of failing silently.
96
+
5
97
  ## What's New in v2.95.1
6
98
 
7
99
  Withdraws a recommendation this project shipped two releases ago. **`drp
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.95.1-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)
6
6
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
7
7
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
8
8
  [![Tools](https://img.shields.io/badge/MCP%20Tools-34%20(353%20full)-blue.svg)](#server-modes)
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.95.1-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)
6
6
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
7
7
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
8
8
  [![Tools](https://img.shields.io/badge/MCP%20Tools-34%20(353%20full)-blue.svg)](#服务器模式)
@@ -12,7 +12,7 @@
12
12
  [![Python](https://img.shields.io/badge/python-3.10+-green.svg)](https://www.python.org/downloads/)
13
13
  [![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://opensource.org/licenses/MIT)
14
14
 
15
- > 本翻译对应 v2.95.1 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
15
+ > 本翻译对应 v2.95.3 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
16
16
 
17
17
  一个 Model Context Protocol (MCP) 服务器,让 AI 助手通过官方脚本 API 控制 DaVinci Resolve Studio(达芬奇)。它提供完整的 API 覆盖,外加带护栏的工作流助手,涵盖剪辑、媒体池整理、渲染设置、审阅标记、调色、Fusion、Fairlight、项目生命周期任务、扩展开发,以及不碰源媒体的媒体分析。
18
18
 
@@ -113,6 +113,26 @@ Resolve validation before release. Use disposable projects and synthetic media
113
113
  only. Never modify, transcode, proxy, or create derivatives of source media
114
114
  unless the user explicitly requests it.
115
115
 
116
+ **Changes to the offline `.drp` authoring tier must run the round-trip harness,
117
+ and a structural readback is NOT sufficient evidence on its own:**
118
+
119
+ ```bash
120
+ RESOLVE_VERIFY=1 venv/bin/python tests/live_drp_roundtrip_verification.py
121
+ ```
122
+
123
+ It authors a `.drp` per primitive, imports it into Resolve, asserts intent
124
+ against structural readback, **and exports the composited frame to assert the
125
+ item is visible on screen.** That last assertion exists because
126
+ `place_fusion_title` passes every structural check — right track, frame,
127
+ duration, type, and correctly-encoded text — while rendering nothing. Anything
128
+ that only diffs `GetStart()`/`GetDuration()` will call that green. Known-broken
129
+ cases are declared in the harness rather than skipped, so a fix reports
130
+ `UNEXPECTED PASS` and fails until the docs describing it are updated.
131
+
132
+ The same rule generalises: **"the file round-trips" and "Resolve honours it" are
133
+ different claims.** Only a live import establishes the second one. Do not write
134
+ "verified live" in a doc unless a runnable command produced that result.
135
+
116
136
  Examples:
117
137
 
118
138
  ```bash
@@ -117,7 +117,7 @@ equivalent, blocking full automation.
117
117
  ### Clip speed / retime ratio and speed ramps
118
118
 
119
119
  - **Object:** `TimelineItem`
120
- - **Behavior:** SetProperty exposes only retime *quality* (RetimeProcess, MotionEstimation) and transform/crop/composite/opacity keys — not the speed value itself. There is no way to set a clip to a given % speed, reverse it, or author a speed ramp. Verified against the documented SetProperty key list AND by live mutating attempt on 21.0.0: SetProperty('Speed'|'PlaybackSpeed'|'RetimeSpeed'|'ClipSpeed', 50) all return False, while SetProperty('RetimeProcess', 1) returns True. THE READ SIDE IS AS DEAD AS THE WRITE SIDE, which is easy to miss: re-measured on Studio 19.1.3.7 against a placed item, GetProperty('Speed'), GetProperty('PlaybackSpeed'), GetProperty('RetimeSpeed') and GetProperty('ClipSpeed') ALL return None, and the keyless GetProperty() dict (26 keys on that item) carries no speed value at all — its only retime key is RetimeProcess, which is quality, not ratio. SetProperty('Speed', 1.75) returned False on 19.1.3.7 too, so the write refusal is not specific to 21.0.0. Note the 21.0.0 stamp above covers the SetProperty measurements only. THE SCRIPTING-API xmeml IMPORT BUILDS NO RETIME — and the way it fails is worse than a no-op. First, what Premiere actually writes, because having this backwards is what produced the wrong contract this entry published in 2.79.0–2.79.1 (see CORRECTION below). In an FCP7 XML a retimed clipitem's <in>/<out> live in the POST-RETIME (warped) domain and always span the RECORD duration; pproTicksIn/pproTicksOut carry the TRUE SOURCE position; and <duration> is the file length expressed in the warped domain. A real 200% clip at 24 fps: <in>1957</in> <out>1971</out> — span 14, EQUAL to its record span; pproTicksIn 41425776000000 and pproTicksOut 41722128000000, which at 254016000000/24 = 10584000000 ticks per frame are source frames 3914 and 3942, exactly 1957x2 and 1971x2, a 28-frame source span over a 14-frame record span; <duration>24292</duration> for a 48584-frame file; and a graphdict mapping warped to true source with the ratio as its slope (when 17910 -> value 35820). So for a retimed clip <in> and pproTicksIn are SUPPOSED to disagree, by exactly the ratio. The same relationship seen from the other side is already encoded in this repo: resolve-advanced/server/prproj.mjs derives Premiere speed from tick geometry as |srcSpan / recSpan| * 100, reversing when in > out. Against that convention, measured on 19.1.3/19.1.3.7: (a) the importer IGNORES the scalar Time Remap speed filter and the clips arrive at 100%; (b) `graphdict` is ignored too — re-tested in Premiere's exact convention with one 100% control clip and one 200% clip per timeline, a document carrying warped <in>/<out>, true-source pproTicks, <duration> = fileLen/ratio and a constant-slope graphdict imports cleanly, the control lands correct, and NO retime is built: recalibrated 2026-08-05, every xmeml-imported clip carries a DEGENERATE time map in Project.db (Sm2TimeMap with an empty source axis — five Time Remap shapes re-measured, 15/15 clips degenerate), so no speed exists in the project data, and the API source witness reads 0/0 on those clips (see WITNESS CALIBRATION below); emitting the identical document WITHOUT the graphdict gives the identical result; (c) `reverse` does not survive either; (d) THE HAZARD, and it is the part that bites: Resolve reads <in> LITERALLY as the true source frame, honouring neither the ticks nor the graphdict. Import a genuine Premiere XML that contains retimes and every retimed clip is placed at in / ratio — the 200% clip above lands on source frame 1957 instead of 3914. There is no error, the cut lengths are still correct, every clip is linked and online, and the timeline renders — so it reads as a good conform while sitting at the wrong moment of the right file. This is the same failure class as the Avid AAF camera-file link (docs/guides/conforming-an-avid-aaf.md): wrong in a way only a frame comparison against a reference can see. SCOPE: all of the above is the SCRIPTING-API import (ImportTimelineFromFile). Resolve's UI importer (File > Import > Timeline) has NOT been tested, and that is how editors usually conform a Premiere XML — do not read this as covering it. CORRECTION: this entry as published in 2.79.0–2.79.1 also claimed that any <in>/<pproTicksIn> inconsistency is silently REJECTED in both orientations. That claim was FALSE and has been removed — it came from an emitter writing ticks = in x ticks-per-frame at every speed, so what it observed was its own malformed files being refused. The graphdict evidence published with it (dead in FOUR shapes, 0 of 2 landed, a 200% clip emitted in 200 / out 296 'clamped' to out 248) described that same malformed input being normalized and is replaced by the re-test above. The conclusion is unchanged; only its evidence is. Placement is NOT the problem: the same route imported 573 clips with 572 of 573 matching by track and record position with source frames exact, and the importer BUILT a 59-frame dissolve. The retime gap is specific, not general. TRAP: Resolve's own FCP7 export cannot witness a speed. It writes a DEGENERATE Time Remap on every clip — `speed` value 0 (not 100) and a graphdict whose keyframe `value`s are all 0 while its `when`s carry the clip's source in/out — so anyone verifying a retime by round-tripping through EXPORT_FCP_7_XML is reading furniture, and the identity Time Remap blocks present on every clip are what make the route look like it should work. WITNESS CALIBRATION (2026-08-05, Studio 19.1.3.7) — the positive control this entry previously lacked now exists, and it RETRACTS the witness the 2.80.0 revision of this entry recommended. The rig removed every confound: the SAME clip placed twice, adjacent, in ONE timeline, the second copy hand-set to 200% in the UI (the only way to make one — see above). GetSourceStartFrame/GetSourceEndFrame separated the copies — 1822..1870 (span 48) at 100% vs 1822..1918 (span 96) at 200% — while GetLeftOffset/GetRightOffset did NOT: 1822..1870 at 100% vs 911..959 at 200%, which is exactly position / 2. GetLeftOffset reports the WARPED (record-side) domain — position / speed, the `In` column of Project.db's Sm2TiItem — so it is exact for PLACEMENT and blind for SPEED BY CONSTRUCTION: its span equals the record span at every speed. The speed itself lives in the item's Sm2TimeMap blob (keyframe slope = ratio; the hand-set 200% reads slope exactly 2.0), which is what GetSourceStart/EndFrame and EXPORT_EDL read. THE SPEED WITNESS is therefore the GetSourceStart/EndFrame span vs the record duration. CAVEAT: on xmeml-IMPORTED timelines those return 0/0 — the importer leaves the time map's source axis empty — and a 0/0 read is UNKNOWN, never 'no retime'. Cross-checks that work everywhere: the Sm2TimeMap slope read from a saved Project.db, and the EXPORT_EDL M2 rate (rate = fps x speed/100, so 048.0 = 200% at 24 fps; `M2 ... 000.0` on every clip is the degenerate-map furniture of an xmeml import — ignore it). TWO IMPORT ROUTES DO BUILD CONSTANT RETIMES (measured 2026-08-05, media linked, judged via the calibrated witnesses above): (1) OTIO LinearTimeWarp through ImportTimelineFromFile — 200% (src 200..296 over a 48-frame record) and 50% (src 300..324 over a 48-frame record) both landed with correct source in-points; the saved Project.db shows slope 2.0 and 0.5. Emission rules: the document must be Resolve-shaped with TIMECODE-ABSOLUTE source frames (see the ImportTimelineFromFile .otio entry), the effect is `LinearTimeWarp.1` with `time_scalar`, and `source_range.duration` is the RECORD span — OTIO semantics, the time_scalar handles source consumption; sending the source span as the duration builds a spec-correctly longer clip, not a retime. (2) EDL M2 — 200% landed (src 100..196 over a 48-frame record), linked. Author the shape Resolve's own EXPORT_EDL writes: the event line's source span EQUALS the record span even under M2; the `M2 <reel> <rate> <srcInTC>` line carries the play rate in fps (048.0 = 200% at 24); `* FROM CLIP NAME:` comments drive pool linking. UNTESTED as import routes: reverse (negative time_scalar / reverse M2) and varying-speed mapsdo not extrapolate the constant-speed result to them.
120
+ - **Behavior:** SetProperty exposes only retime *quality* (RetimeProcess, MotionEstimation) and transform/crop/composite/opacity keys — not the speed value itself. There is no way to set a clip to a given % speed, reverse it, or author a speed ramp. Verified against the documented SetProperty key list AND by live mutating attempt on 21.0.0: SetProperty('Speed'|'PlaybackSpeed'|'RetimeSpeed'|'ClipSpeed', 50) all return False, while SetProperty('RetimeProcess', 1) returns True. THE READ SIDE IS AS DEAD AS THE WRITE SIDE, which is easy to miss: re-measured on Studio 19.1.3.7 against a placed item, GetProperty('Speed'), GetProperty('PlaybackSpeed'), GetProperty('RetimeSpeed') and GetProperty('ClipSpeed') ALL return None, and the keyless GetProperty() dict (26 keys on that item) carries no speed value at all — its only retime key is RetimeProcess, which is quality, not ratio. SetProperty('Speed', 1.75) returned False on 19.1.3.7 too, so the write refusal is not specific to 21.0.0. Note the 21.0.0 stamp above covers the SetProperty measurements only. THE SCRIPTING-API xmeml IMPORT BUILDS NO RETIME — and the way it fails is worse than a no-op. First, what Premiere actually writes, because having this backwards is what produced the wrong contract this entry published in 2.79.0–2.79.1 (see CORRECTION below). In an FCP7 XML a retimed clipitem's <in>/<out> live in the POST-RETIME (warped) domain and always span the RECORD duration; pproTicksIn/pproTicksOut carry the TRUE SOURCE position; and <duration> is the file length expressed in the warped domain. A real 200% clip at 24 fps: <in>1957</in> <out>1971</out> — span 14, EQUAL to its record span; pproTicksIn 41425776000000 and pproTicksOut 41722128000000, which at 254016000000/24 = 10584000000 ticks per frame are source frames 3914 and 3942, exactly 1957x2 and 1971x2, a 28-frame source span over a 14-frame record span; <duration>24292</duration> for a 48584-frame file; and a graphdict mapping warped to true source with the ratio as its slope (when 17910 -> value 35820). So for a retimed clip <in> and pproTicksIn are SUPPOSED to disagree, by exactly the ratio. The same relationship seen from the other side is already encoded in this repo: resolve-advanced/server/prproj.mjs derives Premiere speed from tick geometry as |srcSpan / recSpan| * 100, reversing when in > out. Against that convention, measured on 19.1.3/19.1.3.7: (a) the importer IGNORES the scalar Time Remap speed filter and the clips arrive at 100%; (b) `graphdict` is ignored too — re-tested in Premiere's exact convention with one 100% control clip and one 200% clip per timeline, a document carrying warped <in>/<out>, true-source pproTicks, <duration> = fileLen/ratio and a constant-slope graphdict imports cleanly, the control lands correct, and NO retime is built: recalibrated 2026-08-05, every xmeml-imported clip carries a DEGENERATE time map in Project.db (Sm2TimeMap with an empty source axis — five Time Remap shapes re-measured, 15/15 clips degenerate), so no speed exists in the project data, and the API source witness reads 0/0 on those clips (see WITNESS CALIBRATION below); emitting the identical document WITHOUT the graphdict gives the identical result; (c) `reverse` does not survive either; (d) THE HAZARD, and it is the part that bites: Resolve reads <in> LITERALLY as the true source frame, honouring neither the ticks nor the graphdict. Import a genuine Premiere XML that contains retimes and every retimed clip is placed at in / ratio — the 200% clip above lands on source frame 1957 instead of 3914. There is no error, the cut lengths are still correct, every clip is linked and online, and the timeline renders — so it reads as a good conform while sitting at the wrong moment of the right file. This is the same failure class as the Avid AAF camera-file link (docs/guides/conforming-an-avid-aaf.md): wrong in a way only a frame comparison against a reference can see. SCOPE: all of the above is the SCRIPTING-API import (ImportTimelineFromFile). Resolve's UI importer (File > Import > Timeline) has NOT been tested, and that is how editors usually conform a Premiere XML — do not read this as covering it. CORRECTION: this entry as published in 2.79.0–2.79.1 also claimed that any <in>/<pproTicksIn> inconsistency is silently REJECTED in both orientations. That claim was FALSE and has been removed — it came from an emitter writing ticks = in x ticks-per-frame at every speed, so what it observed was its own malformed files being refused. The graphdict evidence published with it (dead in FOUR shapes, 0 of 2 landed, a 200% clip emitted in 200 / out 296 'clamped' to out 248) described that same malformed input being normalized and is replaced by the re-test above. The conclusion is unchanged; only its evidence is. Placement is NOT the problem: the same route imported 573 clips with 572 of 573 matching by track and record position with source frames exact, and the importer BUILT a 59-frame dissolve. The retime gap is specific, not general. TRAP: Resolve's own FCP7 export cannot witness a speed. It writes a DEGENERATE Time Remap on every clip — `speed` value 0 (not 100) and a graphdict whose keyframe `value`s are all 0 while its `when`s carry the clip's source in/out — so anyone verifying a retime by round-tripping through EXPORT_FCP_7_XML is reading furniture, and the identity Time Remap blocks present on every clip are what make the route look like it should work. WITNESS CALIBRATION (2026-08-05, Studio 19.1.3.7) — the positive control this entry previously lacked now exists, and it RETRACTS the witness the 2.80.0 revision of this entry recommended. The rig removed every confound: the SAME clip placed twice, adjacent, in ONE timeline, the second copy hand-set to 200% in the UI (the only way to make one — see above). GetSourceStartFrame/GetSourceEndFrame separated the copies — 1822..1870 (span 48) at 100% vs 1822..1918 (span 96) at 200% — while GetLeftOffset/GetRightOffset did NOT: 1822..1870 at 100% vs 911..959 at 200%, which is exactly position / 2. GetLeftOffset reports the WARPED (record-side) domain — position / speed, the `In` column of Project.db's Sm2TiItem — so it is exact for PLACEMENT and blind for SPEED BY CONSTRUCTION: its span equals the record span at every speed. The speed itself lives in the item's Sm2TimeMap blob (keyframe slope = ratio; the hand-set 200% reads slope exactly 2.0), which is what GetSourceStart/EndFrame and EXPORT_EDL read. THE SPEED WITNESS is therefore the GetSourceStart/EndFrame span vs the record duration. CAVEAT: on xmeml-IMPORTED timelines those return 0/0 — the importer leaves the time map's source axis empty — and a 0/0 read is UNKNOWN, never 'no retime'. Cross-checks that work everywhere: the Sm2TimeMap slope read from a saved Project.db, and the EXPORT_EDL M2 rate (rate = fps x speed/100, so 048.0 = 200% at 24 fps; `M2 ... 000.0` on every clip is the degenerate-map furniture of an xmeml import — ignore it). TWO IMPORT ROUTES DO BUILD CONSTANT RETIMES (measured 2026-08-05, media linked, judged via the calibrated witnesses above): (1) OTIO LinearTimeWarp through ImportTimelineFromFile — 200% (src 200..296 over a 48-frame record) and 50% (src 300..324 over a 48-frame record) both landed with correct source in-points; the saved Project.db shows slope 2.0 and 0.5. Emission rules: the document must be Resolve-shaped with TIMECODE-ABSOLUTE source frames (see the ImportTimelineFromFile .otio entry), the effect is `LinearTimeWarp.1` with `time_scalar`, and `source_range.duration` is the RECORD span — OTIO semantics, the time_scalar handles source consumption; sending the source span as the duration builds a spec-correctly longer clip, not a retime. (2) EDL M2 — 200% landed (src 100..196 over a 48-frame record), linked. Author the shape Resolve's own EXPORT_EDL writes: the event line's source span EQUALS the record span even under M2; the `M2 <reel> <rate> <srcInTC>` line carries the play rate in fps (048.0 = 200% at 24); `* FROM CLIP NAME:` comments drive pool linking. REVERSE AND VARYING-SPEED RAMPS ARE NOW MEASURED TOO (2026-08-12, Studio 21.0.4.5) — this entry previously warned they were UNTESTED; that warning is closed and both work. REVERSE lands through BOTH import routes: OTIO with a negative time_scalar (-1) placed a clip reading GetSourceStartFrame 95 -> GetSourceEndFrame 46, and EDL with a negative M2 rate (-24.0 at 24fps) read 48 -> 0. So THE API DOES EXPOSE DIRECTION, which is worth knowing: a reversed clip reports GetSourceStartFrame GREATER THAN GetSourceEndFrame (a negative span). VARYING-SPEED RAMPS cannot be expressed in OTIO (LinearTimeWarp is a single time_scalar — constant by construction) but CAN be authored offline and survive Resolve intact: a Sm2TimeMap built with media-timemap.buildTimemap carrying two segments (0-2s record at 0.5x, 2-4s at 2.0x) was patched into a clip's MediaTimemapBA, imported, and re-exported by Resolve with the segments UNCHANGED. Independent confirmation from the API side: the placed clip read GetSourceStart/EndFrame 0..120 over a 96-frame record, and 2s at 0.5x (24 source frames) + 2s at 2.0x (96) is exactly 120. TWO TRAPS worth naming. (1) buildTimemap returns a BUFFER; writing it into the XML without .toString('hex') embeds mojibake, and the failure is SILENT the clip imports fine and reads 0..0, which is indistinguishable from the degenerate-map signature this entry describes for xmeml imports, so it reads as 'Resolve rejected the retime' when it is really a caller bug. (2) a REVERSED map does not start at (0,0): Resolve encodes the starting source offset as a TOP-LEVEL protobuf field 2 double, and each keyframe point omits whichever of record/source is zero (protobuf default-omission). Anything reading fixed offsets or assuming a (0,0) origin decodes a reverse as SPEED 0 rather than -1. drp-format's own decoder had both bugs and is fixed.
121
121
  - **Workaround / current handling:** Set clip speed/retime in the Resolve UI, or BUILD it by import: OTIO LinearTimeWarp and EDL M2 both construct constant retimes through ImportTimelineFromFile (measured — emission rules in reality above); xmeml does not, in any Time Remap shape. To READ a retime back, judge speed by the GetSourceStart/EndFrame span vs the record duration — a 0/0 read (xmeml-imported timelines) is UNKNOWN, never 'no retime' — and cross-check with the Sm2TimeMap slope in a saved Project.db or the EXPORT_EDL M2 rate. Do NOT read speed with GetProperty (None), witness it via EXPORT_FCP_7_XML (degenerate), or judge it from GetLeftOffset/GetRightOffset — the 2.80.0 revision of this entry recommended that pair as the witness and it is blind by construction: it reads the warped domain (position / speed) and its span equals the record span at every speed. Keep it for PLACEMENT checks only. Reverse and varying-speed maps remain untested as import routes. And if you are importing a real Premiere XML that contains retimes, treat every retimed clip's source position as WRONG — placed at <in>, i.e. in / ratio — until it is checked against a reference; the lengths and the links will look right.
122
122
  - **Tags:** missing-method, timeline, retime, speed, interchange, silent-failure, unreliable-return
123
123
 
package/install.py CHANGED
@@ -36,7 +36,7 @@ from src.utils.update_check import (
36
36
 
37
37
  # ─── Version ──────────────────────────────────────────────────────────────────
38
38
 
39
- VERSION = "2.95.1"
39
+ VERSION = "2.95.3"
40
40
  # Only hard floor: mcp[cli] requires Python 3.10+. There is no upper bound —
41
41
  # Resolve's scripting bridge loads into newer interpreters on recent builds
42
42
  # (Python 3.14 verified against Resolve Studio 20.3.2). Older Resolve builds
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "davinci-resolve-mcp",
3
- "version": "2.95.1",
3
+ "version": "2.95.3",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -1,8 +1,24 @@
1
1
  # drp-format — offline DaVinci Resolve project authoring & editing
2
2
 
3
3
  Read, author, and edit **real, importable** DaVinci Resolve 21 projects (`.drp`) and timelines
4
- (`.drt`) as files — no Resolve required. Everything here is verified by a live round-trip
5
- (author/edit → `import_project` → `lint`/`clip_where` → re-export decode), not just self-parse.
4
+ (`.drt`) as files — no Resolve required.
5
+
6
+ > **How to verify a claim in this file.** This README previously said "everything here is verified
7
+ > by a live round-trip". It was not: the gate it referred to threw `TODO — implement once fixtures
8
+ > land`, so nobody ever ran it green, and three unbacked "verified live" claims survived here —
9
+ > one of which reached a shipped `api_truth` recommendation before being caught. The round-trip
10
+ > that actually exists is:
11
+ >
12
+ > ```bash
13
+ > RESOLVE_VERIFY=1 venv/bin/python tests/live_drp_roundtrip_verification.py
14
+ > ```
15
+ >
16
+ > It authors a `.drp` per primitive, imports it into a running Resolve, asserts intent against
17
+ > structural readback, **and exports the composited frame to assert the item is visible on
18
+ > screen.** That last check is the point: `place_fusion_title` satisfies every structural
19
+ > assertion — correct track, frame, duration, type, and text encoded in the blob — while
20
+ > rendering nothing. Structure alone cannot tell you a thing works. Known-broken cases are
21
+ > declared in the harness, so a fix reports `UNEXPECTED PASS` and forces the docs to be updated.
6
22
 
7
23
  Background + the full schema map: `docs/design/drp-drx-drt-closeout-harness/knowledge/resolve21-schema-reconciliation.md`
8
24
  and `.../resolve-authoring-completion.md`.
@@ -18,7 +18,6 @@ const os = require('node:os');
18
18
  const JSZip = require('jszip');
19
19
 
20
20
  const drpFormat = require('..');
21
- const { resolveVerifyTest } = require('./_resolve-verify');
22
21
 
23
22
  // A plausible-shaped synthetic body. The injector does not parse the bytes,
24
23
  // only substitutes them. Hex characters only.
@@ -229,10 +228,19 @@ test('injectGrades: internals — extractDrxBodyHex throws on missing Body', ()
229
228
  // the byte-identical-render check requires Resolve. When the env flag is
230
229
  // unset the test skips with a clear marker.
231
230
 
232
- resolveVerifyTest('injectGrades: rendered frame matches direct DRX apply', async () => {
233
- // The harness's resolve-verifications.md has the full recipe. The actual
234
- // implementation will live here once a captured fixture + a Resolve session
235
- // is in flight. Until then it's a placeholder that exists to demonstrate
236
- // the resolveVerifyTest gate and to surface in the skip list as a TODO.
237
- throw new Error('TODO implement once fixtures/inject-target.drp + Resolve session land');
238
- });
231
+ // NOT a throwing placeholder any more. It used to `throw new Error('TODO ...')`,
232
+ // which meant RESOLVE_VERIFY=1 reported a FAILING suite and everyone learned to
233
+ // ignore the flag which is a large part of why this package carried three
234
+ // unbacked "verified live" claims. A gate nobody can run green is worse than no
235
+ // gate. The live round-trip that DOES exist now lives on the Python side, where
236
+ // the Resolve scripting API is reachable directly:
237
+ //
238
+ // RESOLVE_VERIFY=1 venv/bin/python tests/live_drp_roundtrip_verification.py
239
+ //
240
+ // It authors a .drp per primitive, imports it into Resolve, asserts intent
241
+ // against structural readback AND exports the composited frame to assert the
242
+ // item is actually VISIBLE — the check that catches place_fusion_title, which
243
+ // passes every structural assertion while rendering nothing.
244
+ test.skip('injectGrades: rendered frame matches direct DRX apply '
245
+ + '(covered by tests/live_drp_roundtrip_verification.py — grade-render compare still TODO)',
246
+ () => {});
@@ -117,3 +117,59 @@ test('identityTimemap builds a [02][end,0,end,0,end] map', () => {
117
117
  assert.strictEqual(b.seconds.length, 5);
118
118
  assert.strictEqual(Math.round(b.seconds[0] * (30000 / 1001)), 4575);
119
119
  });
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
+ });
@@ -41,17 +41,69 @@ function _isKeyedForm(b) {
41
41
  * (fixed64 LE doubles). The map starts at the implicit (0,0); constant speed has one keyframe,
42
42
  * a variable-speed ramp has one keyframe per added speed point.
43
43
  */
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
+ */
44
87
  function _decodeKeyframes(hex) {
45
- if (hex == null) return [];
88
+ if (hex == null) return { origin: { recordSec: 0, sourceSec: 0 }, keyframes: [] };
46
89
  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
+ };
50
102
  }
51
103
 
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];
55
107
  const segs = [];
56
108
  for (let i = 1; i < pts.length; i++) {
57
109
  const dr = pts[i].recordSec - pts[i - 1].recordSec;
@@ -68,8 +120,8 @@ function decodeTimemap(input) {
68
120
  const get = (k) => { const e = entries.find((x) => x.key === k); return e ? e.value : undefined; };
69
121
  const recordDurationSec = get('XMax');
70
122
  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);
73
125
  // The EXACT speed lives in the keyframe ratios (source/record per segment); XMax and
74
126
  // LastValidYOffset are frame-quantized. `speed` is the first segment's (whole clip if 1 kf);
75
127
  // `segments` carries the full variable-speed ramp.
@@ -87,7 +87,7 @@ if not logging.getLogger().handlers:
87
87
  handlers=[logging.StreamHandler()],
88
88
  )
89
89
 
90
- VERSION = "2.95.1"
90
+ VERSION = "2.95.3"
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.95.1"
14
+ VERSION = "2.95.3"
15
15
 
16
16
  import base64
17
17
  import os
@@ -1057,9 +1057,39 @@ API_TRUTH: List[Dict[str, Any]] = [
1057
1057
  "source span EQUALS the record span even under M2; the "
1058
1058
  "`M2 <reel> <rate> <srcInTC>` line carries the play rate in "
1059
1059
  "fps (048.0 = 200% at 24); `* FROM CLIP NAME:` comments drive "
1060
- "pool linking. UNTESTED as import routes: reverse (negative "
1061
- "time_scalar / reverse M2) and varying-speed maps do not "
1062
- "extrapolate the constant-speed result to them.",
1060
+ "pool linking. REVERSE AND VARYING-SPEED RAMPS ARE NOW "
1061
+ "MEASURED TOO (2026-08-12, Studio 21.0.4.5) — this entry "
1062
+ "previously warned they were UNTESTED; that warning is "
1063
+ "closed and both work. REVERSE lands through BOTH import "
1064
+ "routes: OTIO with a negative time_scalar (-1) placed a clip "
1065
+ "reading GetSourceStartFrame 95 -> GetSourceEndFrame 46, and "
1066
+ "EDL with a negative M2 rate (-24.0 at 24fps) read 48 -> 0. "
1067
+ "So THE API DOES EXPOSE DIRECTION, which is worth knowing: a "
1068
+ "reversed clip reports GetSourceStartFrame GREATER THAN "
1069
+ "GetSourceEndFrame (a negative span). VARYING-SPEED RAMPS "
1070
+ "cannot be expressed in OTIO (LinearTimeWarp is a single "
1071
+ "time_scalar — constant by construction) but CAN be authored "
1072
+ "offline and survive Resolve intact: a Sm2TimeMap built with "
1073
+ "media-timemap.buildTimemap carrying two segments (0-2s "
1074
+ "record at 0.5x, 2-4s at 2.0x) was patched into a clip's "
1075
+ "MediaTimemapBA, imported, and re-exported by Resolve with "
1076
+ "the segments UNCHANGED. Independent confirmation from the "
1077
+ "API side: the placed clip read GetSourceStart/EndFrame 0..120 "
1078
+ "over a 96-frame record, and 2s at 0.5x (24 source frames) + "
1079
+ "2s at 2.0x (96) is exactly 120. TWO TRAPS worth naming. "
1080
+ "(1) buildTimemap returns a BUFFER; writing it into the XML "
1081
+ "without .toString('hex') embeds mojibake, and the failure is "
1082
+ "SILENT — the clip imports fine and reads 0..0, which is "
1083
+ "indistinguishable from the degenerate-map signature this "
1084
+ "entry describes for xmeml imports, so it reads as 'Resolve "
1085
+ "rejected the retime' when it is really a caller bug. "
1086
+ "(2) a REVERSED map does not start at (0,0): Resolve encodes "
1087
+ "the starting source offset as a TOP-LEVEL protobuf field 2 "
1088
+ "double, and each keyframe point omits whichever of "
1089
+ "record/source is zero (protobuf default-omission). Anything "
1090
+ "reading fixed offsets or assuming a (0,0) origin decodes a "
1091
+ "reverse as SPEED 0 rather than -1. drp-format's own decoder "
1092
+ "had both bugs and is fixed.",
1063
1093
  "recommended": "Set clip speed/retime in the Resolve UI, or BUILD it by "
1064
1094
  "import: OTIO LinearTimeWarp and EDL M2 both construct "
1065
1095
  "constant retimes through ImportTimelineFromFile (measured "