davinci-resolve-mcp 2.103.2 → 2.103.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,68 @@
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.103.3
6
+
7
+ **A batch transcription fix that would have failed every clip.** Issue #160 by
8
+ @techsolvehq-source was real and precisely reported: a Whisper transcription that
9
+ hit the 90s wall-clock cap already returned `success: False`, but
10
+ `execute_plan_async` then hard-set `clip_result["success"] = True`, so the batch
11
+ job counted the clip as succeeded, closed as `completed`, and left `last_error`
12
+ unset. PR #161 by @Steve0x2a fixed that by giving transcription the failure
13
+ annotation vision already had.
14
+
15
+ The gate it landed with keyed on `success` alone, and that is where it went wrong.
16
+ Transcription is enabled by default, and `allow_model_download` is off by default,
17
+ so `_transcribe` returns `success: False, status: "skipped"` on a stock install —
18
+ no Whisper backend, or one the user has not opted into model downloads for. Every
19
+ clip of every batch would have been marked failed and no batch job could have
20
+ reached `completed`. Vision can treat every non-success as a failure because
21
+ vision defaults to *disabled*; transcription cannot.
22
+
23
+ So the failure class is now drawn by status rather than by `success`: skipped,
24
+ disabled, and not_implemented mean the backend never ran, while timeouts, caps
25
+ refusals, and backend errors are real failures. The original timeout bug stays
26
+ fixed.
27
+
28
+ **Antigravity's config path, settled without picking a winner.** Issue #159 by
29
+ @KMiNT21 reports `~/.gemini/config/mcp_config.json`; commit 85afe82 wrote
30
+ `~/.gemini/antigravity/mcp_config.json`. Neither is verifiable from macOS, and
31
+ swapping one unverifiable path for another is a coin flip that breaks it for
32
+ whichever contributor was right — this repo has already been bitten by a
33
+ documented-but-decoy config path (Claude Desktop MSIX, issue #93). The installer
34
+ now probes: `~/.gemini/config/` first, because the installer has never written
35
+ there, so that file existing is evidence something else created it. It looks for
36
+ the file rather than the directory, since `~/.gemini/antigravity/` holds runtime
37
+ state on every install.
38
+
39
+ ### Fixed
40
+
41
+ - A transcription backend that is unavailable, disabled, or not implemented no
42
+ longer marks a batch clip failed. `transcription_attempt_failed` in
43
+ `src/utils/media_analysis.py` screens the "never ran" statuses out of the
44
+ failure class; timeouts, caps refusals, and backend errors stay in it.
45
+ - Whisper wall-clock timeouts are reported as failed batch clips instead of
46
+ silently succeeding (#160, PR #161 by @Steve0x2a).
47
+ - `install.py` resolves Antigravity's MCP config path from what is on disk
48
+ instead of a hard-coded guess (#159).
49
+ - `scripts/gen_api_limitations.py --help` prints usage instead of silently
50
+ overwriting the generated report (PR #163 by @diesdaas).
51
+
52
+ ### Changed
53
+
54
+ - The read/write symmetry audit resolves `_unknown(action, ...)` groups through
55
+ the AST rather than a regex, so action lists expressed as named, starred,
56
+ annotated, or concatenated module constants are now scanned. This surfaced a
57
+ genuine readback gap (`set_clip_marks`) the regex was missing, and known
58
+ readback aliases such as `get_cache_enabled` and `mcp_update_status` no longer
59
+ register as false gaps (PR #162 by @diesdaas).
60
+ - `scripts/audit_readwrite_symmetry.py` writes `docs/reference/readwrite-symmetry.md`
61
+ by default and gains `--check` and `--stdout`, matching the
62
+ `gen_api_limitations.py` convention. The report is pinned to `src/server.py` by
63
+ a drift guard, so it needed a regeneration path: the check now runs in the
64
+ release checklist, and both the script and the failing test name the command
65
+ that clears it.
66
+
5
67
  ## What's New in v2.103.2
6
68
 
7
69
  **A Windows setup that failed with nothing to read.** Reported in issue #158 by
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.103.2-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-2.103.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-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.103.2-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-2.103.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-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.103.2 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
15
+ > 本翻译对应 v2.103.3 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
16
16
 
17
17
  一个 Model Context Protocol (MCP) 服务器,让 AI 助手通过官方脚本 API 控制 DaVinci Resolve Studio(达芬奇)。它提供完整的 API 覆盖,外加带护栏的工作流助手,涵盖剪辑、媒体池整理、渲染设置、审阅标记、调色、Fusion、Fairlight、项目生命周期任务、扩展开发,以及不碰源媒体的媒体分析。
18
18
 
package/docs/install.md CHANGED
@@ -86,7 +86,7 @@ The installer can automatically configure any of these clients:
86
86
 
87
87
  | Client | Config Written To |
88
88
  |--------|-------------------|
89
- | Antigravity (Google) | `~/.gemini/antigravity/mcp_config.json` (all platforms) |
89
+ | Antigravity (Google) | `~/.gemini/config/mcp_config.json`, or `~/.gemini/antigravity/mcp_config.json` if that file already exists (all platforms) |
90
90
  | Claude Desktop | `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS); `%APPDATA%\Claude\claude_desktop_config.json` (Windows, see MSIX note below) |
91
91
  | Claude Code | `.mcp.json` (project root) |
92
92
  | Cursor | `~/.cursor/mcp.json` |
@@ -73,6 +73,7 @@ venv/bin/python tests/test_import.py
73
73
  npm install --package-lock-only --no-audit --no-fund # re-stage package-lock.json if it moved
74
74
  venv/bin/python scripts/audit_api_parity.py
75
75
  venv/bin/python scripts/gen_api_limitations.py --check
76
+ venv/bin/python scripts/audit_readwrite_symmetry.py --check
76
77
  node scripts/agent-rules/generate.mjs --check
77
78
  venv/bin/python -m unittest tests.test_static_undefined_names tests.test_duplicate_definitions tests.test_action_list_drift tests.test_panel_docs_drift tests.test_doc_tool_counts tests.test_agent_rules_drift
78
79
  node bin/davinci-resolve-mcp.mjs --help
@@ -2,24 +2,21 @@
2
2
 
3
3
  # Read/Write Symmetry Audit
4
4
 
5
- - write-style actions scanned: **106**
6
- - with a matching read: **57**
7
- - `set_` actions with no `get_`/`list_` (real readback gaps): **11**
5
+ - write-style action occurrences scanned: **116**
6
+ - write-style action occurrences with a matching read: **67**
7
+ - distinct high-signal `set_` actions without a direct/known readback: **8**
8
8
 
9
- ## High-signal gaps — `set_` with no read counterpart
9
+ ## High-signal gaps — `set_` with no direct/known readback
10
10
 
11
- - `set_cache`
12
11
  - `set_cdl`
12
+ - `set_clip_marks`
13
13
  - `set_clips_linked`
14
14
  - `set_high_priority`
15
15
  - `set_keyframe_interpolation`
16
- - `set_mcp_update_policy`
17
16
  - `set_name`
18
17
  - `set_node_enabled`
19
18
  - `set_title_text`
20
- - `set_track_enable`
21
- - `set_track_lock`
22
19
 
23
- ## Low-signal (create/add/insert/import — usually expected): 37
20
+ ## Low-signal (create/add/insert/apply/import — usually expected): 40 distinct names
24
21
 
25
- `add_clip_mattes`, `add_comp`, `add_subfolder`, `add_sync_event_markers`, `add_timeline_mattes`, `add_track`, `add_version`, `apply_arri_cdl_lut`, `apply_fairlight_preset`, `apply_grade_from_drx`, `apply_look_to_items`, `apply_spec`, `create_compound_clip`, `create_fusion_clip`, `create_magic_mask`, `create_stereo_clip`, `create_subtitles`, `create_timeline`, `create_timeline_from_clips`, `create_variant_from_ranges`, `import_burnin`, `import_comp`, `import_folder`, `import_into_timeline`, `import_media`, `import_preset`, `import_project`, `import_render`, `import_timeline`, `import_to_pool`, `insert_audio`, `insert_fusion_composition`, `insert_fusion_generator`, `insert_fusion_title`, `insert_generator`, `insert_ofx_generator`, `insert_title`
22
+ `add_clip_mattes`, `add_comp`, `add_fusion_mask`, `add_subfolder`, `add_sync_event_markers`, `add_timeline_mattes`, `add_track`, `add_version`, `apply_arri_cdl_lut`, `apply_cuts`, `apply_fairlight_preset`, `apply_grade_from_drx`, `apply_look_to_items`, `apply_spec`, `create_compound_clip`, `create_fusion_clip`, `create_magic_mask`, `create_stereo_clip`, `create_subtitles`, `create_timeline`, `create_timeline_from_clips`, `create_variant_from_ranges`, `import_comp`, `import_folder`, `import_from_drp`, `import_into_timeline`, `import_media`, `import_preset`, `import_project`, `import_render`, `import_timeline`, `import_timeline_checked`, `import_to_pool`, `insert_audio`, `insert_fusion_composition`, `insert_fusion_generator`, `insert_fusion_title`, `insert_generator`, `insert_ofx_generator`, `insert_title`
package/install.py CHANGED
@@ -37,7 +37,7 @@ from src.utils.update_check import (
37
37
 
38
38
  # ─── Version ──────────────────────────────────────────────────────────────────
39
39
 
40
- VERSION = "2.103.2"
40
+ VERSION = "2.103.3"
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
@@ -393,6 +393,33 @@ def vscode_global_storage():
393
393
  return xdg_config() / "Code" / "User" / "globalStorage"
394
394
 
395
395
 
396
+ def antigravity_config():
397
+ """Antigravity's MCP config path, resolved by what is on disk (issue #159).
398
+
399
+ Two contributors report two different locations and neither is verifiable
400
+ from here: 85afe82 added ~/.gemini/antigravity/mcp_config.json, and #159
401
+ reports ~/.gemini/config/mcp_config.json, with ~/.gemini/antigravity/ being
402
+ runtime state (logs, crash reports, brain state). Swapping one unverifiable
403
+ path for the other is a coin flip that breaks it for whoever was right, and
404
+ this repo has already been bitten by a documented-but-decoy config path
405
+ (Claude Desktop MSIX, issue #93).
406
+
407
+ So probe instead of choosing. ~/.gemini/config/ is checked first because
408
+ the installer has never written there — if that file exists, something else
409
+ created it, which is real evidence. ~/.gemini/antigravity/mcp_config.json
410
+ may exist merely because an earlier run of this installer put it there.
411
+ With neither present, fall back to ~/.gemini/config/ as #159 documents.
412
+ """
413
+ candidates = (
414
+ home() / ".gemini" / "config" / "mcp_config.json",
415
+ home() / ".gemini" / "antigravity" / "mcp_config.json",
416
+ )
417
+ for candidate in candidates:
418
+ if candidate.exists():
419
+ return candidate
420
+ return candidates[0]
421
+
422
+
396
423
  # Each client entry:
397
424
  # id, name, config_path_fn, config_key, merge_strategy, notes
398
425
  # config_path_fn returns the path; config_key is the JSON key wrapping the server entry
@@ -402,7 +429,7 @@ MCP_CLIENTS = [
402
429
  {
403
430
  "id": "antigravity",
404
431
  "name": "Antigravity",
405
- "get_path": lambda: home() / ".gemini" / "antigravity" / "mcp_config.json",
432
+ "get_path": antigravity_config,
406
433
  "config_key": "mcpServers",
407
434
  "notes": "Google's agentic AI coding assistant (VS Code fork)",
408
435
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "davinci-resolve-mcp",
3
- "version": "2.103.2",
3
+ "version": "2.103.3",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -7,15 +7,22 @@ asymmetries. The goal is to find write-without-read gaps before users have to
7
7
  the repeatable feature-discovery method behind R5.
8
8
 
9
9
  Reads the `_unknown(action, [...])` lists in src/server.py, which enumerate every
10
- action a tool accepts. Prints a markdown report.
10
+ action a tool accepts. Writes docs/reference/readwrite-symmetry.md.
11
+
12
+ `--check` verifies the committed report matches the current server surface; it is
13
+ what tests/test_readwrite_audit.py and the release checklist gate on. `--stdout`
14
+ prints the report instead of writing it.
11
15
  """
16
+ import argparse
12
17
  import ast
13
18
  import os
14
- import re
15
19
  import sys
16
20
 
17
21
  ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
18
22
  SERVER = os.path.join(ROOT, "src", "server.py")
23
+ DOC_PATH = os.path.join(ROOT, "docs", "reference", "readwrite-symmetry.md")
24
+ DOC_REL = os.path.relpath(DOC_PATH, ROOT)
25
+ REGEN_COMMAND = "venv/bin/python scripts/audit_readwrite_symmetry.py"
19
26
 
20
27
  READ_PREFIXES = ("get_", "list_", "probe_", "is_", "has_", "find_")
21
28
  # `set_` is the high-signal class: a set with no get is a genuine readback gap.
@@ -24,20 +31,94 @@ READ_PREFIXES = ("get_", "list_", "probe_", "is_", "has_", "find_")
24
31
  HIGH_SIGNAL = ("set_",)
25
32
  LOW_SIGNAL = ("add_", "create_", "insert_", "apply_", "import_")
26
33
 
34
+ STEM_READ_ALIASES = {
35
+ # Resolve's public names often use enable/lock verbs on writes but enabled/
36
+ # locked nouns on reads; keep the audit focused on real missing readbacks.
37
+ "cache": ("cache_enabled",),
38
+ "caps_preset": ("caps",),
39
+ "track_enable": ("track_enabled",),
40
+ "track_lock": ("track_locked",),
41
+ }
42
+
43
+ DIRECT_READ_ALIASES = {
44
+ # mcp_update_status returns the persisted prompt policy and effective
45
+ # decision, so set_mcp_update_policy has a direct readback without a get_
46
+ # name.
47
+ "mcp_update_policy": ("mcp_update_status",),
48
+ }
49
+
50
+
51
+ def _module_constants(tree: ast.Module):
52
+ """Collect module-level named assignments without evaluating the module."""
53
+ constants = {}
54
+ for node in tree.body:
55
+ if isinstance(node, ast.Assign):
56
+ targets = node.targets
57
+ elif isinstance(node, ast.AnnAssign):
58
+ targets = (node.target,)
59
+ else:
60
+ continue
61
+ for target in targets:
62
+ if isinstance(target, ast.Name):
63
+ constants[target.id] = node.value
64
+ return constants
65
+
66
+
67
+ def _resolve_actions(expr, constants, seen=None):
68
+ """Resolve supported action-list expressions or return None."""
69
+ seen = set() if seen is None else seen
70
+ if isinstance(expr, ast.Constant) and isinstance(expr.value, str):
71
+ return [expr.value]
72
+ if isinstance(expr, ast.Name):
73
+ if expr.id in seen or expr.id not in constants:
74
+ return None
75
+ return _resolve_actions(expr=constants[expr.id], constants=constants,
76
+ seen=seen | {expr.id})
77
+ if isinstance(expr, (ast.List, ast.Tuple)):
78
+ actions = []
79
+ for element in expr.elts:
80
+ if isinstance(element, ast.Starred):
81
+ element = element.value
82
+ resolved = _resolve_actions(element, constants, seen)
83
+ if resolved is None:
84
+ return None
85
+ actions.extend(resolved)
86
+ return actions
87
+ if isinstance(expr, ast.BinOp) and isinstance(expr.op, ast.Add):
88
+ left = _resolve_actions(expr.left, constants, seen)
89
+ right = _resolve_actions(expr.right, constants, seen)
90
+ if left is None or right is None:
91
+ return None
92
+ return left + right
93
+ return None
94
+
27
95
 
28
96
  def _action_lists(src: str):
29
- """Yield lists of action-name string literals from each _unknown(action, [...])."""
30
- for m in re.finditer(r"_unknown\(action,\s*\[(.*?)\]\)", src, re.DOTALL):
31
- names = re.findall(r'"([a-z][a-z0-9_]*)"', m.group(1))
32
- if names:
33
- yield names
97
+ """Yield action lists passed to ``_unknown(action, ...)`` calls."""
98
+ tree = ast.parse(src)
99
+ constants = _module_constants(tree)
100
+ for node in ast.walk(tree):
101
+ if not isinstance(node, ast.Call):
102
+ continue
103
+ if not (isinstance(node.func, ast.Name)
104
+ and node.func.id == "_unknown"
105
+ and len(node.args) >= 2
106
+ and isinstance(node.args[0], ast.Name)
107
+ and node.args[0].id == "action"):
108
+ continue
109
+ actions = _resolve_actions(node.args[1], constants)
110
+ if actions is not None:
111
+ yield actions
34
112
 
35
113
 
36
114
  def _has_read(stem: str, aset: set) -> bool:
37
115
  # Match get_<stem>, list_<stem>, and plural get_<stem>s (e.g. add_keyframe -> get_keyframes).
38
- candidates = {rp + stem for rp in READ_PREFIXES}
39
- candidates |= {rp + stem + "s" for rp in READ_PREFIXES}
40
- candidates |= {rp + stem.rstrip("s") for rp in READ_PREFIXES}
116
+ stems = {stem, *STEM_READ_ALIASES.get(stem, ())}
117
+ candidates = set(DIRECT_READ_ALIASES.get(stem, ()))
118
+ for candidate_stem in stems:
119
+ candidates |= {rp + candidate_stem for rp in READ_PREFIXES}
120
+ candidates |= {rp + candidate_stem + "s" for rp in READ_PREFIXES}
121
+ candidates |= {rp + candidate_stem.rstrip("s") for rp in READ_PREFIXES}
41
122
  return bool(candidates & aset)
42
123
 
43
124
 
@@ -60,19 +141,76 @@ def audit(src: str):
60
141
  return total, covered, sorted(high), sorted(low)
61
142
 
62
143
 
63
- def main():
64
- src = open(SERVER, encoding="utf-8").read()
144
+ def render_report(src: str) -> str:
65
145
  total, covered, high, low = audit(src)
66
- print("# Read/Write Symmetry Audit\n")
67
- print(f"- write-style actions scanned: **{total}**")
68
- print(f"- with a matching read: **{covered}**")
69
- print(f"- `set_` actions with no `get_`/`list_` (real readback gaps): **{len(high)}**\n")
146
+ lines = [
147
+ "<!-- Generated by scripts/audit_readwrite_symmetry.py — do not edit by hand. -->",
148
+ "",
149
+ "# Read/Write Symmetry Audit",
150
+ "",
151
+ f"- write-style action occurrences scanned: **{total}**",
152
+ f"- write-style action occurrences with a matching read: **{covered}**",
153
+ f"- distinct high-signal `set_` actions without a direct/known readback: **{len(high)}**",
154
+ "",
155
+ ]
70
156
  if high:
71
- print("## High-signal gaps — `set_` with no read counterpart\n")
157
+ lines += [
158
+ "## High-signal gaps — `set_` with no direct/known readback",
159
+ "",
160
+ ]
72
161
  for a in high:
73
- print(f"- `{a}`")
74
- print(f"\n## Low-signal (create/add/insert/import — usually expected): {len(low)}\n")
75
- print(", ".join(f"`{a}`" for a in low))
162
+ lines.append(f"- `{a}`")
163
+ lines += [
164
+ "",
165
+ f"## Low-signal (create/add/insert/apply/import — usually expected): "
166
+ f"{len(low)} distinct names",
167
+ "",
168
+ ", ".join(f"`{a}`" for a in low),
169
+ ]
170
+ return "\n".join(lines).rstrip() + "\n"
171
+
172
+
173
+ def _parse_args(argv):
174
+ parser = argparse.ArgumentParser(description=__doc__)
175
+ parser.add_argument(
176
+ "--check",
177
+ action="store_true",
178
+ help="fail if the committed report is out of date",
179
+ )
180
+ parser.add_argument(
181
+ "--stdout",
182
+ action="store_true",
183
+ help="print the report instead of writing it",
184
+ )
185
+ return parser.parse_args(argv)
186
+
187
+
188
+ def main(argv=None):
189
+ if argv is None:
190
+ argv = sys.argv[1:]
191
+ args = _parse_args(argv)
192
+ with open(SERVER, encoding="utf-8") as fh:
193
+ src = fh.read()
194
+ content = render_report(src)
195
+ if args.stdout:
196
+ print(content, end="")
197
+ return 0
198
+ if args.check:
199
+ current = ""
200
+ if os.path.exists(DOC_PATH):
201
+ with open(DOC_PATH, encoding="utf-8") as fh:
202
+ current = fh.read()
203
+ if current != content:
204
+ print(
205
+ f"STALE: {DOC_REL} is out of date.\nRun: {REGEN_COMMAND}",
206
+ file=sys.stderr,
207
+ )
208
+ return 1
209
+ print(f"OK: {DOC_REL} is up to date.")
210
+ return 0
211
+ with open(DOC_PATH, "w", encoding="utf-8") as fh:
212
+ fh.write(content)
213
+ print(f"Wrote {DOC_REL}")
76
214
  return 0
77
215
 
78
216
 
@@ -17,6 +17,7 @@ Run with ``--check`` to fail (exit 1) when the committed doc is stale.
17
17
  """
18
18
  from __future__ import annotations
19
19
 
20
+ import argparse
20
21
  import sys
21
22
  from pathlib import Path
22
23
 
@@ -122,9 +123,22 @@ def render() -> str:
122
123
  return "\n".join(out).rstrip() + "\n"
123
124
 
124
125
 
126
+ def _parse_args(argv: list[str]) -> argparse.Namespace:
127
+ parser = argparse.ArgumentParser(description=__doc__)
128
+ parser.add_argument(
129
+ "--check",
130
+ action="store_true",
131
+ help="fail if the committed report is out of date",
132
+ )
133
+ return parser.parse_args(argv)
134
+
135
+
125
136
  def main(argv: list[str]) -> int:
137
+ if argv is None:
138
+ raise TypeError("argv must not be None")
139
+ args = _parse_args(argv)
126
140
  content = render()
127
- if "--check" in argv:
141
+ if args.check:
128
142
  current = DOC_PATH.read_text() if DOC_PATH.exists() else ""
129
143
  if current != content:
130
144
  print(
@@ -87,7 +87,7 @@ if not logging.getLogger().handlers:
87
87
  handlers=[logging.StreamHandler()],
88
88
  )
89
89
 
90
- VERSION = "2.103.2"
90
+ VERSION = "2.103.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.103.2"
14
+ VERSION = "2.103.3"
15
15
 
16
16
  import base64
17
17
  import os
@@ -200,6 +200,62 @@ def _annotate_clip_vision_failure(clip_result: Dict[str, Any], vision: Any) -> N
200
200
  })
201
201
 
202
202
 
203
+ # Transcription statuses that mean "never attempted", as opposed to "attempted
204
+ # and did not produce a transcript". Transcription is on by default
205
+ # (DEFAULT_TRANSCRIPTION_ENABLED) and allow_model_download is off by default, so
206
+ # a machine with no Whisper backend — or one that has simply not opted into model
207
+ # downloads — reports "skipped" on every clip. Those are configuration states,
208
+ # not clip failures, and counting them as failures fails every clip of every
209
+ # batch on a default install.
210
+ TRANSCRIPTION_UNATTEMPTED_STATUSES = frozenset({"skipped", "disabled", "not_implemented"})
211
+
212
+
213
+ def transcription_attempt_failed(transcript: Any, *, enabled: bool) -> bool:
214
+ """True when requested transcription ran and still produced no transcript.
215
+
216
+ The vision counterpart is visual_analysis_completed, but vision defaults to
217
+ disabled, so it can treat every non-success as a failure. Transcription
218
+ defaults to enabled, so it has to separate "the backend refused to start" —
219
+ which is the normal state of an install without Whisper — from a real
220
+ failure such as a wall-clock timeout, a caps refusal, or a backend error.
221
+ """
222
+ if not enabled or not isinstance(transcript, dict):
223
+ return False
224
+ if transcript.get("success"):
225
+ return False
226
+ status = str(transcript.get("status") or "").strip().lower()
227
+ return status not in TRANSCRIPTION_UNATTEMPTED_STATUSES
228
+
229
+
230
+ def _annotate_clip_transcript_failure(clip_result: Dict[str, Any], transcript: Any) -> None:
231
+ """Mark a clip failed when requested transcription did not complete.
232
+
233
+ execute_plan_async writes the analysis JSON and then hard-sets success True.
234
+ Vision already overwrites that via _annotate_clip_vision_failure; a
235
+ transcription timeout had no equivalent, so batch jobs counted an empty
236
+ transcript as succeeded. Callers gate on transcription_attempt_failed, which
237
+ keeps the "backend never ran" statuses out of the failure class.
238
+ """
239
+ status = ""
240
+ reason = ""
241
+ if isinstance(transcript, dict):
242
+ status = str(transcript.get("status") or "").strip()
243
+ reason = str(transcript.get("reason") or transcript.get("error") or "").strip()
244
+ if status == "wall_clock_timeout":
245
+ message = "Transcription timed out before a transcript was produced."
246
+ if reason:
247
+ message = f"{message} {reason}"
248
+ elif reason:
249
+ message = f"Transcription was requested but did not complete: {reason}"
250
+ else:
251
+ message = "Transcription was requested but did not complete."
252
+ clip_result.update({
253
+ "success": False,
254
+ "error": message,
255
+ "transcription": transcript,
256
+ })
257
+
258
+
203
259
  def _annotate_partial_success(manifest: Dict[str, Any]) -> None:
204
260
  """D3 — Mark batch manifests with explicit completed/failed clip-id lists.
205
261
 
@@ -5697,6 +5753,13 @@ async def execute_plan_async(
5697
5753
  and not vision_pending
5698
5754
  and not visual_analysis_completed(vision)
5699
5755
  )
5756
+ transcript_failed = transcription_attempt_failed(
5757
+ transcript,
5758
+ enabled=_coerce_bool(
5759
+ (options.get("transcription") or {}).get("enabled"),
5760
+ default=DEFAULT_TRANSCRIPTION_ENABLED,
5761
+ ),
5762
+ )
5700
5763
  frame_count = int(clip_plan.get("analysis_keyframe_budget") or 0)
5701
5764
  marker_plan = _build_clip_marker_plan(
5702
5765
  record,
@@ -5757,6 +5820,8 @@ async def execute_plan_async(
5757
5820
  manifest["vision_pending"] = True
5758
5821
  elif vision_failed:
5759
5822
  _annotate_clip_vision_failure(clip_result, vision)
5823
+ if transcript_failed:
5824
+ _annotate_clip_transcript_failure(clip_result, transcript)
5760
5825
  manifest["clips"].append(clip_result)
5761
5826
 
5762
5827
  manifest["completed_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
@@ -745,6 +745,8 @@ def run_batch_job_slice(
745
745
  _event(conn, job_id, "info", f"Clip {row['position'] + 1} {status}", {"clip": clip_result.get("record")})
746
746
  else:
747
747
  error = clip_result.get("error") or manifest.get("error") or "Clip analysis failed"
748
+ if not isinstance(error, str):
749
+ error = str(error)
748
750
  conn.execute(
749
751
  """
750
752
  UPDATE job_clips
@@ -753,6 +755,10 @@ def run_batch_job_slice(
753
755
  """,
754
756
  (error, completed_at, completed_at, row["id"]),
755
757
  )
758
+ conn.execute(
759
+ "UPDATE jobs SET last_error = ?, updated_at = ? WHERE job_id = ?",
760
+ (error, completed_at, job_id),
761
+ )
756
762
  processed.append({"position": row["position"], "status": "failed", "error": error})
757
763
  _event(conn, job_id, "error", f"Clip {row['position'] + 1} failed", {"error": error})
758
764
  except Exception as exc: # pragma: no cover - defensive for arbitrary media/tool failures
@@ -766,6 +772,10 @@ def run_batch_job_slice(
766
772
  (str(exc), completed_at, completed_at, row["id"]),
767
773
  )
768
774
  processed.append({"position": row["position"], "status": "failed", "error": str(exc)})
775
+ conn.execute(
776
+ "UPDATE jobs SET last_error = ?, updated_at = ? WHERE job_id = ?",
777
+ (str(exc), completed_at, job_id),
778
+ )
769
779
  _event(conn, job_id, "error", f"Clip {row['position'] + 1} raised an exception", {"error": str(exc)})
770
780
  _sync_job_counts(conn, job_id)
771
781
  conn.commit()