davinci-resolve-mcp 2.73.2 → 2.74.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,27 @@
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.74.0
6
+
7
+ ### Added
8
+
9
+ - **`drp-format/set-framerate` — relabel a `.drp` timeline frame rate in place.**
10
+ `setTimelineFrameRate(drpInput, targetFps)` rewrites the timeline
11
+ `<FrameRate>` blob(s) to a new fps while leaving every clip's integer
12
+ Start/Duration/In/Out and every clip-level `<MediaFrameRate>` untouched — a
13
+ relabel, not a retime. Use it to fix a contaminated rate tag (e.g. an export
14
+ step that stamped 23.976 onto a 24.000 timeline whose frames are correct).
15
+ Offline-only by necessity: Resolve locks a timeline's frame rate once the
16
+ timeline exists, so an imported `.drp` can never be relabelled through
17
+ Resolve itself. `readTimelineFrameRates(drpInput)` reports the current
18
+ rate(s) without modifying anything. Both are exported from the `drp-format`
19
+ index; five node:test cases cover relabel, `MediaFrameRate` isolation,
20
+ idempotence, and input validation.
21
+
22
+ ### Fixed
23
+
24
+ - Corrected a garbled doc comment in `drx-parameters/index.js`.
25
+
5
26
  ## What's New in v2.73.2
6
27
 
7
28
  Two honesty fixes in the conform path, both found by running a real 83-minute
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # DaVinci Resolve MCP Server
2
2
 
3
- [![Version](https://img.shields.io/badge/version-2.73.2-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
3
+ [![Version](https://img.shields.io/badge/version-2.74.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
4
4
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
5
5
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
6
6
  [![Tools](https://img.shields.io/badge/MCP%20Tools-34%20(341%20full)-blue.svg)](#server-modes)
package/install.py CHANGED
@@ -36,7 +36,7 @@ from src.utils.update_check import (
36
36
 
37
37
  # ─── Version ──────────────────────────────────────────────────────────────────
38
38
 
39
- VERSION = "2.73.2"
39
+ VERSION = "2.74.0"
40
40
  # Only hard floor: mcp[cli] requires Python 3.10+. There is no upper bound —
41
41
  # Resolve's scripting bridge loads into newer interpreters on recent builds
42
42
  # (Python 3.14 verified against Resolve Studio 20.3.2). Older Resolve builds
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "davinci-resolve-mcp",
3
- "version": "2.73.2",
3
+ "version": "2.74.0",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -0,0 +1,62 @@
1
+ const test = require('node:test');
2
+ const assert = require('node:assert');
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const JSZip = require('jszip');
6
+ const { setTimelineFrameRate, readTimelineFrameRates } = require('../set-framerate');
7
+
8
+ // A real Resolve export with one media clip — carries a timeline <FrameRate> blob
9
+ // and a clip-level <MediaFrameRate> that a relabel must NOT touch.
10
+ const TEMPLATE = path.join(__dirname, '..', 'templates', 'media-clip-h264.drp');
11
+
12
+ async function countTag(buf, tag) {
13
+ const zip = await JSZip.loadAsync(buf);
14
+ let n = 0;
15
+ for (const name of Object.keys(zip.files)) {
16
+ if (!name.endsWith('.xml')) continue;
17
+ const xml = await zip.files[name].async('string');
18
+ n += (xml.match(new RegExp(`<${tag}>[0-9a-fA-F]{32}</${tag}>`, 'g')) || []).length;
19
+ }
20
+ return n;
21
+ }
22
+
23
+ test('readTimelineFrameRates reports the template timeline rate', async () => {
24
+ const rates = await readTimelineFrameRates(TEMPLATE);
25
+ assert.ok(rates.length > 0, 'expected at least one timeline FrameRate blob');
26
+ for (const r of rates) {
27
+ assert.ok(Number.isFinite(r.fps) && r.fps > 0, `decoded fps should be positive, got ${r.fps}`);
28
+ }
29
+ });
30
+
31
+ test('setTimelineFrameRate relabels every timeline FrameRate blob', async () => {
32
+ const before = await readTimelineFrameRates(TEMPLATE);
33
+ const target = Math.abs(before[0].fps - 25) < 1e-6 ? 24 : 25;
34
+
35
+ const { buffer, changes, timelineFrameRates } = await setTimelineFrameRate(TEMPLATE, target);
36
+ assert.ok(changes.length > 0, 'a real rate change must be recorded');
37
+ assert.deepStrictEqual(timelineFrameRates, before.map(r => r.fps));
38
+
39
+ const after = await readTimelineFrameRates(buffer);
40
+ assert.strictEqual(after.length, before.length, 'blob count must not change');
41
+ for (const r of after) assert.ok(Math.abs(r.fps - target) < 1e-6, `expected ${target}, got ${r.fps}`);
42
+ });
43
+
44
+ test('relabel leaves clip-level MediaFrameRate blobs untouched', async () => {
45
+ const original = fs.readFileSync(TEMPLATE);
46
+ const mediaBefore = await countTag(original, 'MediaFrameRate');
47
+ const { buffer } = await setTimelineFrameRate(original, 30);
48
+ const mediaAfter = await countTag(buffer, 'MediaFrameRate');
49
+ assert.strictEqual(mediaAfter, mediaBefore, 'MediaFrameRate must be left alone');
50
+ });
51
+
52
+ test('relabel is idempotent: same target twice yields no recorded change the second time', async () => {
53
+ const first = await setTimelineFrameRate(TEMPLATE, 25);
54
+ const second = await setTimelineFrameRate(first.buffer, 25);
55
+ assert.strictEqual(second.changes.length, 0, 'second pass at same fps records no change');
56
+ });
57
+
58
+ test('rejects invalid targets', async () => {
59
+ await assert.rejects(() => setTimelineFrameRate(TEMPLATE, 0), /positive finite number/);
60
+ await assert.rejects(() => setTimelineFrameRate(TEMPLATE, NaN), /positive finite number/);
61
+ await assert.rejects(() => setTimelineFrameRate(TEMPLATE, 'fast'), /positive finite number/);
62
+ });
@@ -153,6 +153,10 @@ module.exports = {
153
153
  // Author a project with one media clip referencing an arbitrary h264 file, from scratch [P8].
154
154
  addMediaClip: require('./author-project').addMediaClip,
155
155
 
156
+ // Relabel the timeline frame rate tag in place (not a retime — frame counts untouched).
157
+ setTimelineFrameRate: require('./set-framerate').setTimelineFrameRate,
158
+ readTimelineFrameRates: require('./set-framerate').readTimelineFrameRates,
159
+
156
160
  // Offline media relink — repoint media to new paths in the Media Pool blobs (no Resolve).
157
161
  relinkMedia: require('./relink-media').relinkMedia,
158
162
  // Relink + fix cached specs (resolution/frames/fps) for a differently-formatted file [P8].
@@ -0,0 +1,97 @@
1
+ /**
2
+ * set-framerate — **relabel** a Resolve `.drp` timeline frame rate in place.
3
+ *
4
+ * This is a *relabel*, not a retime: it rewrites the timeline `FrameRate` blob(s) to a new fps
5
+ * while leaving every clip's integer Start/Duration/In/Out and every `MediaFrameRate` (clip
6
+ * *source* rate) untouched. The frame count is identical; only the rate the timeline is
7
+ * interpreted at changes. Use this to fix a contaminated tag — e.g. a caption/export step that
8
+ * stamped 23.976 onto a 24.000 timeline whose frames are actually correct.
9
+ *
10
+ * Why offline: Resolve locks a timeline's frame rate once the timeline exists (writable only when
11
+ * a project has 0 timelines), so an imported `.drp` can never be relabelled *through* Resolve —
12
+ * only retimed.
13
+ *
14
+ * Where fps lives in a real DRP (verified against templates/media-clip-h264.drp, a Resolve export):
15
+ * - timeline rate → `<FrameRate>[double fps][double 0]</FrameRate>` in MediaPool/<folder>/MpFolder.xml
16
+ * - clip source → `<MediaFrameRate>…</MediaFrameRate>` (LEFT ALONE — different tag)
17
+ * - project.xml / Gallery.xml carry no fps.
18
+ * The exact tag `<FrameRate>` never substring-matches `<MediaFrameRate>` (char before "FrameRate"
19
+ * is "a", not "<"), so a `<FrameRate>`-scoped rewrite is inherently clip-safe.
20
+ *
21
+ * @module drp-format/set-framerate
22
+ */
23
+
24
+ const fs = require('node:fs');
25
+ const JSZip = require('jszip');
26
+ const { decodeRateBlob, encodeRateBlob } = require('./media-blobs');
27
+
28
+ // Exact timeline FrameRate blob element. `[^]` (not `.`) so a stray newline can't break the match.
29
+ const FRAMERATE_BLOB_RE = /<FrameRate>([0-9a-fA-F]{32})<\/FrameRate>/g;
30
+
31
+ async function loadZip(drpInput) {
32
+ const buf = Buffer.isBuffer(drpInput) ? drpInput : await fs.promises.readFile(drpInput);
33
+ return JSZip.loadAsync(buf);
34
+ }
35
+
36
+ /**
37
+ * Relabel every timeline FrameRate blob in a `.drp` to `targetFps`.
38
+ *
39
+ * @param {string|Buffer} drpInput Path to a `.drp` (or its Buffer).
40
+ * @param {number} targetFps New timeline fps (e.g. 24, 23.976, 25).
41
+ * @returns {Promise<{ buffer: Buffer, changes: Array<{entry:string, from:number, to:number}>, timelineFrameRates:number[] }>}
42
+ * @throws if the target is not a finite positive number or no timeline FrameRate blob is found.
43
+ */
44
+ async function setTimelineFrameRate(drpInput, targetFps) {
45
+ if (typeof targetFps !== 'number' || !Number.isFinite(targetFps) || targetFps <= 0) {
46
+ throw new Error(`set-framerate: targetFps must be a positive finite number, got ${targetFps}`);
47
+ }
48
+ const zip = await loadZip(drpInput);
49
+ const newHex = encodeRateBlob(targetFps); // [double fps][double 0], 32 hex chars
50
+
51
+ const entries = [];
52
+ zip.forEach((p, e) => { if (!e.dir && /\.xml$/i.test(p)) entries.push(p); });
53
+
54
+ const changes = [];
55
+ const seenRates = [];
56
+ for (const entry of entries) {
57
+ const xml = await zip.file(entry).async('string');
58
+ if (!FRAMERATE_BLOB_RE.test(xml)) continue;
59
+ FRAMERATE_BLOB_RE.lastIndex = 0;
60
+ const next = xml.replace(FRAMERATE_BLOB_RE, (_m, hex) => {
61
+ const from = decodeRateBlob(hex);
62
+ if (from != null) seenRates.push(from);
63
+ // Round-trip guard: only record a real change; still rewrite so the blob's
64
+ // trailing 8 bytes are canonicalised to zero.
65
+ if (from == null || Math.abs(from - targetFps) > 1e-6) {
66
+ changes.push({ entry, from: from ?? NaN, to: targetFps });
67
+ }
68
+ return `<FrameRate>${newHex}</FrameRate>`;
69
+ });
70
+ zip.file(entry, next);
71
+ }
72
+
73
+ if (seenRates.length === 0) {
74
+ throw new Error('set-framerate: no timeline <FrameRate> blob found — not a .drp with a timeline?');
75
+ }
76
+
77
+ const buffer = await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' });
78
+ return { buffer, changes, timelineFrameRates: seenRates };
79
+ }
80
+
81
+ /** Read-only: report the timeline frame rate(s) in a `.drp` without modifying it. */
82
+ async function readTimelineFrameRates(drpInput) {
83
+ const zip = await loadZip(drpInput);
84
+ const rates = [];
85
+ const entries = [];
86
+ zip.forEach((p, e) => { if (!e.dir && /\.xml$/i.test(p)) entries.push(p); });
87
+ for (const entry of entries) {
88
+ const xml = await zip.file(entry).async('string');
89
+ for (const m of xml.matchAll(FRAMERATE_BLOB_RE)) {
90
+ const fps = decodeRateBlob(m[1]);
91
+ if (fps != null) rates.push({ entry, fps });
92
+ }
93
+ }
94
+ return rates;
95
+ }
96
+
97
+ module.exports = { setTimelineFrameRate, readTimelineFrameRates };
@@ -10,7 +10,7 @@
10
10
  * - Protobuf encoding/decoding
11
11
  * - Validation and correction
12
12
  *
13
- * Used across the The project platform for consistent DRX handling.
13
+ * Used across the platform for consistent DRX handling.
14
14
  *
15
15
  * @module drx-parameters
16
16
  *
@@ -85,7 +85,7 @@ if not logging.getLogger().handlers:
85
85
  handlers=[logging.StreamHandler()],
86
86
  )
87
87
 
88
- VERSION = "2.73.2"
88
+ VERSION = "2.74.0"
89
89
  logger = logging.getLogger("davinci-resolve-mcp")
90
90
  logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
91
91
  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 341-tool granular server instead
12
12
  """
13
13
 
14
- VERSION = "2.73.2"
14
+ VERSION = "2.74.0"
15
15
 
16
16
  import base64
17
17
  import os