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 +62 -0
- package/README.md +1 -1
- package/README.zh-CN.md +2 -2
- package/docs/install.md +1 -1
- package/docs/process/release-process.md +1 -0
- package/docs/reference/readwrite-symmetry.md +7 -10
- package/install.py +29 -2
- package/package.json +1 -1
- package/scripts/audit_readwrite_symmetry.py +158 -20
- package/scripts/gen_api_limitations.py +15 -1
- package/src/granular/common.py +1 -1
- package/src/server.py +1 -1
- package/src/utils/media_analysis.py +65 -0
- package/src/utils/media_analysis_jobs.py +10 -0
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
|
-
[](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
|
|
6
6
|
[](https://www.npmjs.com/package/davinci-resolve-mcp)
|
|
7
7
|
[](docs/reference/api-coverage.md)
|
|
8
8
|
[-blue.svg)](#server-modes)
|
package/README.zh-CN.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[English](README.md) | 简体中文
|
|
4
4
|
|
|
5
|
-
[](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
|
|
6
6
|
[](https://www.npmjs.com/package/davinci-resolve-mcp)
|
|
7
7
|
[](docs/reference/api-coverage.md)
|
|
8
8
|
[-blue.svg)](#服务器模式)
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
[](https://www.python.org/downloads/)
|
|
13
13
|
[](https://opensource.org/licenses/MIT)
|
|
14
14
|
|
|
15
|
-
> 本翻译对应 v2.103.
|
|
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
|
|
6
|
-
- with a matching read: **
|
|
7
|
-
- `set_` actions
|
|
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
|
|
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):
|
|
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`, `
|
|
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.
|
|
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":
|
|
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
|
@@ -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.
|
|
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
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
39
|
-
candidates
|
|
40
|
-
|
|
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
|
|
64
|
-
src = open(SERVER, encoding="utf-8").read()
|
|
144
|
+
def render_report(src: str) -> str:
|
|
65
145
|
total, covered, high, low = audit(src)
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
157
|
+
lines += [
|
|
158
|
+
"## High-signal gaps — `set_` with no direct/known readback",
|
|
159
|
+
"",
|
|
160
|
+
]
|
|
72
161
|
for a in high:
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
|
141
|
+
if args.check:
|
|
128
142
|
current = DOC_PATH.read_text() if DOC_PATH.exists() else ""
|
|
129
143
|
if current != content:
|
|
130
144
|
print(
|
package/src/granular/common.py
CHANGED
|
@@ -87,7 +87,7 @@ if not logging.getLogger().handlers:
|
|
|
87
87
|
handlers=[logging.StreamHandler()],
|
|
88
88
|
)
|
|
89
89
|
|
|
90
|
-
VERSION = "2.103.
|
|
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
|
@@ -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()
|