davinci-resolve-mcp 2.104.4 → 2.104.6

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,71 @@
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.104.6
6
+
7
+ **A correction to the v2.104.2 StartFrame fix — measured against Resolve
8
+ itself.** SMPTE non-drop timecode counts NOMINAL frames: the fields multiply
9
+ by the integer base (30 for 29.97, 24 for 23.976), not the exact rate.
10
+ Measured live on Studio 19.1.3.7: a 29.97 timeline at 01:00:00:00 reads
11
+ GetStartFrame 108000 = 3600 x 30, and a 23.976 one reads 86400 = 3600 x 24.
12
+ Issue #168's reporter expected round(3600 x 30000/1001) = 107892 — they said
13
+ plainly they had patched defensively without verifying Resolve — and the
14
+ v2.104.2 fix shipped that expectation. Both the original fractional product
15
+ and the rounded 107892 were wrong; the Python converters (which always used
16
+ nominal) and the Node converters now agree.
17
+
18
+ Three Node converters move to nominal-base counting, with drop-frame
19
+ handling (semicolon timecodes) matching the Python formula:
20
+
21
+ - `drt.author`'s SeqContainer StartFrame (01:00:00:00 at 29.97 now writes
22
+ 108000; at 23.976, 86400)
23
+ - `editorial.tcToFrames` — the exact-rate product undercounted NTSC
24
+ timecode by 0.1% (108 frames per hour), which touched every EDL/AAF
25
+ source/record conversion at 29.97
26
+ - `media-inventory.tcToFrames` — whose own framesToTc was already nominal,
27
+ so the tc->frames->tc round trip was asymmetric at NTSC rates until now
28
+
29
+ The conform fixtures are integer-rate, which is how the exact-rate
30
+ convention survived: nothing in the suite exercised an NTSC timecode
31
+ conversion end to end. Regression tests now pin the measured nominal values
32
+ and a drop-frame case.
33
+
34
+ ## What's New in v2.104.5
35
+
36
+ The recent bug classes, generalized into guards — and the sweeps found the
37
+ kwarg bug a second time.
38
+
39
+ **PR #165's bug existed twice.** The positional-only bridge rule was guarded
40
+ for src/server.py alone; sweeping ALL of src/ found
41
+ `StartRendering(isInteractiveMode=...)` again in the render-deliver probe
42
+ catalogue. Fixed, and the guard is rebuilt properly: it parses the Resolve
43
+ method names out of the shipped API reference and flags keyword arguments on
44
+ exactly those calls across the whole tree — which is what separates
45
+ StartRendering from Popen without drowning in stdlib false positives.
46
+
47
+ **Closing a project mid-render is now unreachable through this server.** The
48
+ wedge documented in v2.104.0 (orphaned render, stuck IsRenderingInProgress,
49
+ 0% jobs, refused Quit) could still be triggered via project_manager.close or
50
+ a disposable-project delete. `close` now refuses while a render is in
51
+ progress — with the wedge named in the remediation — and accepts
52
+ stop_render=true to stop, wait for the flag to clear, and close.
53
+ delete_project_safely auto-stops first (deleting kills the render anyway;
54
+ stopping is strictly better) and refuses when the flag will not clear, which
55
+ is the already-wedged state where no delete ends well. Live-verified both
56
+ paths on Studio 19.1.3.7: mid-render close refused, stop_render=true stopped
57
+ and closed cleanly, no wedge.
58
+
59
+ **Audits that came back clean, on the record:** the remaining default-ON
60
+ analysis gates (marker plan is built unconditionally, vision is default-OFF
61
+ behind a capability gate) cannot reproduce the cache-poisoning shape, and the
62
+ Python tree carries no numeric-keyed hex tables of the kind that rotted in
63
+ the Node encoders.
64
+
65
+ PR #166's discarded-return guard fired on this release's own
66
+ StopRendering call — third catch in three releases; the allowlist entry
67
+ records that the helper verifies by polling the flag, stronger than the None
68
+ the API returns.
69
+
5
70
  ## What's New in v2.104.4
6
71
 
7
72
  Hardening pass over the classes the v2.104.2 batch exposed, live-verified on
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.104.4-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-2.104.6-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-36%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.104.4-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-2.104.6-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-36%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.104.4 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
15
+ > 本翻译对应 v2.104.6 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
16
16
 
17
17
  一个 Model Context Protocol (MCP) 服务器,让 AI 助手通过官方脚本 API 控制 DaVinci Resolve Studio(达芬奇)。它提供完整的 API 覆盖,外加带护栏的工作流助手,涵盖剪辑、媒体池整理、渲染设置、审阅标记、调色、Fusion、Fairlight、项目生命周期任务、扩展开发,以及不碰源媒体的媒体分析。
18
18
 
package/install.py CHANGED
@@ -37,7 +37,7 @@ from src.utils.update_check import (
37
37
 
38
38
  # ─── Version ──────────────────────────────────────────────────────────────────
39
39
 
40
- VERSION = "2.104.4"
40
+ VERSION = "2.104.6"
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.104.4",
3
+ "version": "2.104.6",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -23,10 +23,21 @@ const require = createRequire(import.meta.url);
23
23
  // ── timecode ───────────────────────────────────────────────────────────
24
24
  const TC_RE = /^(\d{2}):(\d{2}):(\d{2})[:;](\d{2,3})$/;
25
25
  export function tcToFrames(tc, fps) {
26
- const m = TC_RE.exec(String(tc).trim());
26
+ const raw = String(tc).trim();
27
+ const m = TC_RE.exec(raw);
27
28
  if (!m || !fps) return null;
28
29
  const [, h, mm, s, f] = m.map(Number);
29
- return Math.round((h * 3600 + mm * 60 + s) * fps) + f;
30
+ // SMPTE timecode counts NOMINAL frames (base 30 for 29.97, 24 for 23.976)
31
+ // measured against Resolve's own GetStartFrame (Studio 19.1.3.7). The
32
+ // previous exact-rate product undercounted NTSC by 0.1% (108 frames/hour).
33
+ const nominal = Math.round(fps);
34
+ let frames = (h * 3600 + mm * 60 + s) * nominal + f;
35
+ if (raw.includes(';')) {
36
+ const dropped = Math.round(nominal * 0.0666666667);
37
+ const totalMinutes = h * 60 + mm;
38
+ frames -= dropped * (totalMinutes - Math.floor(totalMinutes / 10));
39
+ }
40
+ return frames;
30
41
  }
31
42
  const isTc = (t) => TC_RE.test(t);
32
43
 
@@ -17,10 +17,21 @@ import { probeMedia } from './ffprobe-media.mjs';
17
17
  /** "HH:MM:SS:FF" (or ';' DF) → integer frames at fps. Returns null if unparseable. */
18
18
  export function tcToFrames(tc, fps) {
19
19
  if (typeof tc !== 'string' || !fps) return null;
20
- const m = /^(\d{1,2}):(\d{2}):(\d{2})[:;](\d{2,3})$/.exec(tc.trim());
20
+ const raw = tc.trim();
21
+ const m = /^(\d{1,2}):(\d{2}):(\d{2})[:;](\d{2,3})$/.exec(raw);
21
22
  if (!m) return null;
22
23
  const [, h, mm, s, f] = m.map(Number);
23
- return Math.round((h * 3600 + mm * 60 + s) * fps) + f;
24
+ // Nominal-base counting, matching framesToTc below and Resolve's own
25
+ // GetStartFrame (measured, Studio 19.1.3.7) — the exact-rate product made
26
+ // the tc->frames->tc round trip asymmetric at NTSC rates.
27
+ const nominal = Math.round(fps);
28
+ let frames = (h * 3600 + mm * 60 + s) * nominal + f;
29
+ if (raw.includes(';')) {
30
+ const dropped = Math.round(nominal * 0.0666666667);
31
+ const totalMinutes = h * 60 + mm;
32
+ frames -= dropped * (totalMinutes - Math.floor(totalMinutes / 10));
33
+ }
34
+ return frames;
24
35
  }
25
36
 
26
37
  export function framesToTc(frames, fps) {
@@ -34,14 +34,31 @@ test('the three formerly-wrong table rates never reappear', () => {
34
34
  assert.notStrictEqual(encodeFrameRate(59.94), '286b55e253f9ed3f');
35
35
  });
36
36
 
37
- test('StartFrame is an integer at fractional rates and startFrame wins', async () => {
37
+ test('StartFrame counts NOMINAL frames and startFrame wins', async () => {
38
+ // Measured against Resolve itself (Studio 19.1.3.7): 01:00:00:00 at 29.97
39
+ // reads GetStartFrame 108000 = 3600 x 30, and 23.976 reads 86400 = 3600 x
40
+ // 24 — SMPTE NDF counts by the integer base, not the exact rate. Both the
41
+ // pre-#168 fractional product and its first rounded fix (107892) were wrong.
38
42
  const tl = { name: 'T', videoTracks: [], audioTracks: [] };
39
- const fromTc = await buildSeqContainerFile(tl, {
43
+ const ntsc30 = await buildSeqContainerFile(tl, {
40
44
  frameRate: 30000 / 1001, startTimecode: '01:00:00:00',
41
45
  });
42
- assert.match(fromTc, /<StartFrame>107892<\/StartFrame>/);
46
+ assert.match(ntsc30, /<StartFrame>108000<\/StartFrame>/);
47
+ const ntsc24 = await buildSeqContainerFile(tl, {
48
+ frameRate: 24000 / 1001, startTimecode: '01:00:00:00',
49
+ });
50
+ assert.match(ntsc24, /<StartFrame>86400<\/StartFrame>/);
43
51
  const explicit = await buildSeqContainerFile(tl, {
44
52
  frameRate: 30000 / 1001, startTimecode: '01:00:00:00', startFrame: 99999,
45
53
  });
46
54
  assert.match(explicit, /<StartFrame>99999<\/StartFrame>/);
47
55
  });
56
+
57
+ test('drop-frame start timecode subtracts the dropped numbers', async () => {
58
+ // 00:01:00;02 DF at 29.97: minute 1 drops 2 numbers -> 1800 + 2 - 2 = 1800.
59
+ const tl = { name: 'T', videoTracks: [], audioTracks: [] };
60
+ const df = await buildSeqContainerFile(tl, {
61
+ frameRate: 30000 / 1001, startTimecode: '00:01:00;02',
62
+ });
63
+ assert.match(df, /<StartFrame>1800<\/StartFrame>/);
64
+ });
@@ -363,15 +363,27 @@ function buildMarkersFieldsBlob(markers, frameRate) {
363
363
  function timecodeToFrames(timecode, fps = 24) {
364
364
  if (!timecode || typeof timecode !== 'string') return 0;
365
365
 
366
- const parts = timecode.split(':').map(Number);
367
- if (parts.length !== 4) return 0;
366
+ const dropFrame = timecode.includes(';');
367
+ const parts = timecode.replace(/;/g, ':').split(':').map(Number);
368
+ if (parts.length !== 4 || parts.some(Number.isNaN)) return 0;
368
369
 
369
370
  const [hh, mm, ss, ff] = parts;
370
- // A frame index is an integer. At fractional rates the seconds product is
371
- // fractional (3600 x 30000/1001 = 107892.107...), and writing it raw put a
372
- // fractional <StartFrame> in the XML (issue #168). Non-drop convention:
373
- // round the seconds part, then add the frame component.
374
- return Math.round(hh * 3600 * fps + mm * 60 * fps + ss * fps) + ff;
371
+ // SMPTE timecode counts NOMINAL frames: the fields multiply by the integer
372
+ // base (30 for 29.97, 24 for 23.976), not the exact rate. Measured against
373
+ // Resolve itself (Studio 19.1.3.7): a 29.97 timeline at 01:00:00:00 reads
374
+ // GetStartFrame 108000 = 3600 x 30, and a 23.976 one reads 86400 = 3600 x
375
+ // 24. Both the pre-#168 exact-rate product (107892.107...) and its rounded
376
+ // fix (107892) were wrong; nominal is what Resolve stores.
377
+ const nominal = Math.round(fps);
378
+ let frames = (hh * 3600 + mm * 60 + ss) * nominal + ff;
379
+ if (dropFrame) {
380
+ // Drop-frame skips 2 (or 4 at 59.94) TC numbers per minute except every
381
+ // tenth minute — same formula as the Python _timecode_to_frame_id.
382
+ const dropped = Math.round(nominal * 0.0666666667);
383
+ const totalMinutes = hh * 60 + mm;
384
+ frames -= dropped * (totalMinutes - Math.floor(totalMinutes / 10));
385
+ }
386
+ return frames;
375
387
  }
376
388
 
377
389
  // =============================================================================
@@ -87,7 +87,7 @@ if not logging.getLogger().handlers:
87
87
  handlers=[logging.StreamHandler()],
88
88
  )
89
89
 
90
- VERSION = "2.104.4"
90
+ VERSION = "2.104.6"
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.104.4"
14
+ VERSION = "2.104.6"
15
15
 
16
16
  import base64
17
17
  import os
@@ -17630,7 +17630,11 @@ def project_manager(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
17630
17630
  create(name, media_location_path?) -> {success, name}
17631
17631
  load(name) -> {success}
17632
17632
  save() -> {success}
17633
- close() -> {success}
17633
+ close(stop_render?) -> {success}
17634
+ Refuses while a render is in progress — closing mid-render orphans the
17635
+ render and wedges Resolve's pipeline until restart (stuck
17636
+ IsRenderingInProgress, 0% jobs, refused Quit). stop_render=true stops
17637
+ the render, waits for the flag to clear, then closes.
17634
17638
  delete(name) -> {success}
17635
17639
  import_project(path, name?) -> {success}
17636
17640
  export_project(name, path, with_stills_and_luts?) -> {success}
@@ -17734,7 +17738,33 @@ def project_manager(action: str, params: Optional[Dict[str, Any]] = None) -> Dic
17734
17738
  return {"success": bool(pm.SaveProject())}
17735
17739
  elif action == "close":
17736
17740
  proj = pm.GetCurrentProject()
17737
- return {"success": bool(pm.CloseProject(proj))} if proj else _err("No project open")
17741
+ if not proj:
17742
+ return _err("No project open")
17743
+ # Closing mid-render orphans the render and wedges Resolve's pipeline
17744
+ # (api_truth IsRenderingInProgress entry, measured live). Refuse by
17745
+ # default; stop_render=true stops the render and waits before closing.
17746
+ try:
17747
+ rendering = bool(proj.IsRenderingInProgress())
17748
+ except Exception:
17749
+ rendering = False
17750
+ if rendering:
17751
+ if not p.get("stop_render"):
17752
+ return _err(
17753
+ "A render is in progress on this project. Closing now would "
17754
+ "orphan the render and wedge Resolve's render pipeline — the "
17755
+ "stuck IsRenderingInProgress flag blocks every later render "
17756
+ "and refuses Quit until Resolve is restarted.",
17757
+ category="invalid_input",
17758
+ remediation=(
17759
+ "Wait for the render (poll render.get_job_status), or pass "
17760
+ "stop_render=true to stop it and close once the flag clears."
17761
+ ),
17762
+ )
17763
+ from src.utils.project_cleanup import stop_render_before_close
17764
+ render_state = stop_render_before_close(proj)
17765
+ if not render_state.get("safe"):
17766
+ return _err(render_state["detail"], category="api_error")
17767
+ return {"success": bool(pm.CloseProject(proj))}
17738
17768
  elif action == "delete":
17739
17769
  if not p.get("name"):
17740
17770
  return _err("delete requires name")
@@ -504,7 +504,9 @@ def _probe_render_to_disk(ctx):
504
504
  job = ctx.project.AddRenderJob()
505
505
  if not job:
506
506
  return False
507
- ctx.project.StartRendering(job, isInteractiveMode=False)
507
+ # Positional: the free-edition bridge proxies Resolve calls positionally,
508
+ # and a keyword argument dies inside _BoundMethod (PR #165's bug class).
509
+ ctx.project.StartRendering(job, False)
508
510
  deadline = _t.monotonic() + 420
509
511
  while ctx.project.IsRenderingInProgress() and _t.monotonic() < deadline:
510
512
  _t.sleep(1)
@@ -64,6 +64,52 @@ def save_project_if_safe(pm: Any) -> Dict[str, Any]:
64
64
  return {"saved": False, "skipped": False, "project": name, "reason": repr(exc)}
65
65
 
66
66
 
67
+ def stop_render_before_close(project: Any, *, wait_seconds: float = 8.0) -> Dict[str, Any]:
68
+ """Stop any in-progress render and wait for the flag to clear.
69
+
70
+ Closing or deleting a project while its render runs orphans the render and
71
+ wedges Resolve's whole pipeline: IsRenderingInProgress sticks True on every
72
+ subsequent project, new jobs sit at 0%, StartRendering starts returning
73
+ False, and Quit() is refused behind a modal (api_truth, measured live on
74
+ Studio 19.1.3.7). Returns {safe, was_rendering, waited_seconds, detail}.
75
+ A flag that stays True after StopRendering at idle is the stuck state —
76
+ only restarting Resolve clears it, so `safe` comes back False.
77
+ """
78
+ import time as _time
79
+
80
+ try:
81
+ rendering = bool(project.IsRenderingInProgress())
82
+ except Exception:
83
+ return {"safe": True, "was_rendering": False, "waited_seconds": 0.0,
84
+ "detail": "IsRenderingInProgress unavailable; proceeding"}
85
+ if not rendering:
86
+ return {"safe": True, "was_rendering": False, "waited_seconds": 0.0, "detail": ""}
87
+ try:
88
+ project.StopRendering()
89
+ except Exception:
90
+ pass
91
+ waited = 0.0
92
+ while waited < wait_seconds:
93
+ _time.sleep(0.5)
94
+ waited += 0.5
95
+ try:
96
+ if not project.IsRenderingInProgress():
97
+ return {"safe": True, "was_rendering": True,
98
+ "waited_seconds": waited, "detail": "render stopped"}
99
+ except Exception:
100
+ break
101
+ return {
102
+ "safe": False, "was_rendering": True, "waited_seconds": waited,
103
+ "detail": (
104
+ "IsRenderingInProgress is still True after StopRendering and "
105
+ f"{waited:.0f}s — closing now would orphan the render and wedge "
106
+ "Resolve's render pipeline (stuck flag, 0% jobs, refused Quit). "
107
+ "If Resolve is idle at 0% CPU this is the already-stuck state and "
108
+ "only restarting Resolve clears it."
109
+ ),
110
+ }
111
+
112
+
67
113
  def delete_project_safely(
68
114
  pm: Any,
69
115
  name: str,
@@ -89,6 +135,20 @@ def delete_project_safely(
89
135
  except Exception:
90
136
  current = None
91
137
  if current == name:
138
+ # Deleting the current project kills its render anyway — stopping
139
+ # first is strictly better than orphaning it (the wedge documented
140
+ # in stop_render_before_close). Refuse only when the flag will not
141
+ # clear, which is the already-wedged state where the delete cannot
142
+ # end well either.
143
+ try:
144
+ project = pm.GetCurrentProject()
145
+ except Exception:
146
+ project = None
147
+ if project is not None:
148
+ render_state = stop_render_before_close(project)
149
+ if not render_state.get("safe"):
150
+ return {"success": False, "attempts": 0, "leftover": name,
151
+ "detail": render_state["detail"]}
92
152
  # CloseProject ALWAYS, and first. It is what releases the session's
93
153
  # lock on the project; switching away with LoadProject does not, and
94
154
  # DeleteProject then returns False permanently — retries never help.