davinci-resolve-mcp 2.103.1 → 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 +119 -0
- package/README.md +1 -1
- package/README.zh-CN.md +2 -2
- package/bin/davinci-resolve-mcp.mjs +29 -9
- package/docs/install.md +1 -0
- package/docs/process/release-process.md +1 -0
- package/docs/reference/readwrite-symmetry.md +7 -10
- package/install.py +87 -3
- package/package.json +1 -1
- package/scripts/audit_readwrite_symmetry.py +158 -20
- package/scripts/doctor.py +71 -4
- 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,125 @@
|
|
|
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
|
+
|
|
67
|
+
## What's New in v2.103.2
|
|
68
|
+
|
|
69
|
+
**A Windows setup that failed with nothing to read.** Reported in issue #158 by
|
|
70
|
+
@KMiNT21 on a machine carrying both Python 3.12 and 3.13: `npx davinci-resolve-mcp`
|
|
71
|
+
exited without a traceback, a log line, or an error. Three defects compounded.
|
|
72
|
+
|
|
73
|
+
The first is the one that mattered. The npm launcher tested for the Windows `py`
|
|
74
|
+
launcher with `py --version`, and that result gated the entire `py -3.12 / -3.11 /
|
|
75
|
+
-3.10` candidate list. `py` does not accept `--version` on every build — it exits
|
|
76
|
+
101 on the ones it does not — so on those machines the probe reported no launcher,
|
|
77
|
+
every version-pinned candidate was discarded, and selection fell through to bare
|
|
78
|
+
`python`: the 3.13 that the candidate ordering exists specifically to avoid. The
|
|
79
|
+
3.13 protections added in v2.26.1 were not wrong; they were being skipped past.
|
|
80
|
+
|
|
81
|
+
The fix removes the probe rather than correcting its flag. `checkPython()` already
|
|
82
|
+
validates each candidate by running it, so a machine without `py` costs one failed
|
|
83
|
+
spawn. A probe that can produce a false negative earns its place only if something
|
|
84
|
+
downstream cannot do without it, and nothing here needed it.
|
|
85
|
+
|
|
86
|
+
### Fixed
|
|
87
|
+
|
|
88
|
+
- `bin/davinci-resolve-mcp.mjs` no longer gates the `py -3.x` candidates behind a
|
|
89
|
+
`py --version` probe.
|
|
90
|
+
- An access-violation exit is now explained instead of propagated bare. Windows
|
|
91
|
+
reports `STATUS_ACCESS_VIOLATION` as an exit code (`3221225477`, or `-1073741819`
|
|
92
|
+
read signed), not as a signal — the interpreter dies inside the native library
|
|
93
|
+
with no chance to print. Both the launcher and `install.py`'s connection probe now
|
|
94
|
+
name the code, say why there is no traceback, and give the remedy. Previously the
|
|
95
|
+
probe could only report `Process exited with code 3221225477`.
|
|
96
|
+
- `scripts/doctor.py` consults the runtime discovery helpers when every candidate
|
|
97
|
+
path misses, so Resolve installed off the conventional root (the reporter had it
|
|
98
|
+
on `D:`) is found rather than reported as four FAILs on a machine `install.py` had
|
|
99
|
+
just configured correctly. Same shape as issue #106.
|
|
100
|
+
- `scripts/doctor.py` no longer reports a client config as `missing` because of path
|
|
101
|
+
escaping. A Windows path written into JSON comes back with doubled separators, and
|
|
102
|
+
the literal substring test could never match it — a false negative in the tool
|
|
103
|
+
whose job is to say whether setup worked.
|
|
104
|
+
|
|
105
|
+
### Not changed
|
|
106
|
+
|
|
107
|
+
Python 3.13 is still permitted. The policy set in v2.26.1 is a 3.10 floor with no
|
|
108
|
+
cap — warn, do not block — and issue #158 proposed enforcing 3.10-3.12 on Windows.
|
|
109
|
+
The candidate ordering already prefers the lower-risk interpreters; the bug was that
|
|
110
|
+
ordering being bypassed, which is now fixed.
|
|
111
|
+
|
|
112
|
+
### Coverage and its limits
|
|
113
|
+
|
|
114
|
+
`tests/test_windows_python_crash.py` pins the launcher's candidate shape and the
|
|
115
|
+
crash-code translation; `tests/test_doctor_paths.py` gains the discovery and
|
|
116
|
+
path-escaping cases. All were confirmed to fail against the unfixed code.
|
|
117
|
+
|
|
118
|
+
What is **not** covered, and is not coverable from macOS: whether `py --version`
|
|
119
|
+
actually fails on any given Windows build. That claim comes from the reporter. The
|
|
120
|
+
fix does not rest on it — it removes the probe rather than correcting it, so the
|
|
121
|
+
code no longer has an opinion either way. The access-violation paths are likewise
|
|
122
|
+
tested by injecting the exit code, not by producing a real crash.
|
|
123
|
+
|
|
5
124
|
## What's New in v2.103.1
|
|
6
125
|
|
|
7
126
|
**A loudness measurement could silently become a single frame's reading.**
|
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
|
|
|
@@ -179,14 +179,6 @@ function syncManagedInstall(root) {
|
|
|
179
179
|
return root;
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
-
function commandExists(command, args = []) {
|
|
183
|
-
const result = spawnSync(command, [...args, "--version"], {
|
|
184
|
-
encoding: "utf8",
|
|
185
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
186
|
-
});
|
|
187
|
-
return result.status === 0;
|
|
188
|
-
}
|
|
189
|
-
|
|
190
182
|
function parseExecutable(value) {
|
|
191
183
|
if (!value) {
|
|
192
184
|
return null;
|
|
@@ -207,7 +199,14 @@ function pythonCandidates() {
|
|
|
207
199
|
// Prefer the lowest-ABI-risk interpreters first, then newer ones, then the
|
|
208
200
|
// generic launchers. All 3.10+ are accepted; ordering just picks the safest
|
|
209
201
|
// when several are installed.
|
|
210
|
-
|
|
202
|
+
// No existence probe in front of these. `py --version` is not a reliable
|
|
203
|
+
// one — the Windows launcher does not accept it on every build, and it
|
|
204
|
+
// exits 101 on the ones it does not (issue #158). A probe that gets that
|
|
205
|
+
// wrong discards every version-pinned candidate below and falls through to
|
|
206
|
+
// bare `python`, which is exactly the 3.13 the ordering exists to avoid.
|
|
207
|
+
// checkPython() runs each candidate anyway, so a missing `py` costs one
|
|
208
|
+
// failed spawn and is skipped.
|
|
209
|
+
if (process.platform === "win32") {
|
|
211
210
|
candidates.push(
|
|
212
211
|
{ command: "py", args: ["-3.12"] },
|
|
213
212
|
{ command: "py", args: ["-3.11"] },
|
|
@@ -317,6 +316,24 @@ function venvPython(root) {
|
|
|
317
316
|
return info;
|
|
318
317
|
}
|
|
319
318
|
|
|
319
|
+
// Windows reports a hard access violation as the process exit code, not as a
|
|
320
|
+
// signal and not as a traceback: the interpreter is gone before it can say
|
|
321
|
+
// anything. Loading Resolve's fusionscript under a Python its C ABI does not
|
|
322
|
+
// match is one way to get there, so name that possibility rather than letting
|
|
323
|
+
// the run end in a bare unexplained code (issue #158).
|
|
324
|
+
const WINDOWS_ACCESS_VIOLATION = [3221225477, -1073741819];
|
|
325
|
+
|
|
326
|
+
function accessViolationNote(code) {
|
|
327
|
+
return [
|
|
328
|
+
`The Python process was terminated by an access violation (0x${(code >>> 0).toString(16).toUpperCase()}).`,
|
|
329
|
+
"It crashed inside a native library before it could report anything, so there is no traceback above.",
|
|
330
|
+
"The usual cause is Resolve's scripting library being loaded by a Python whose C ABI it was not built",
|
|
331
|
+
"against. If you are on Python 3.13+, install Python 3.10-3.12 and pin it:",
|
|
332
|
+
" DAVINCI_RESOLVE_MCP_PYTHON=C:\\Path\\To\\python3.12.exe",
|
|
333
|
+
"then re-run setup so the managed venv is rebuilt on that interpreter.",
|
|
334
|
+
].join("\n");
|
|
335
|
+
}
|
|
336
|
+
|
|
320
337
|
function run(command, args, options = {}) {
|
|
321
338
|
const child = spawn(command, args, {
|
|
322
339
|
cwd: options.cwd,
|
|
@@ -329,6 +346,9 @@ function run(command, args, options = {}) {
|
|
|
329
346
|
process.kill(process.pid, signal);
|
|
330
347
|
return;
|
|
331
348
|
}
|
|
349
|
+
if (WINDOWS_ACCESS_VIOLATION.includes(code)) {
|
|
350
|
+
console.error(accessViolationNote(code));
|
|
351
|
+
}
|
|
332
352
|
process.exit(code ?? 1);
|
|
333
353
|
});
|
|
334
354
|
child.on("error", (error) => {
|
package/docs/install.md
CHANGED
|
@@ -86,6 +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/config/mcp_config.json`, or `~/.gemini/antigravity/mcp_config.json` if that file already exists (all platforms) |
|
|
89
90
|
| Claude Desktop | `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS); `%APPDATA%\Claude\claude_desktop_config.json` (Windows, see MSIX note below) |
|
|
90
91
|
| Claude Code | `.mcp.json` (project root) |
|
|
91
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
|
},
|
|
@@ -1253,6 +1280,54 @@ def install_dependencies(venv_path, project_dir):
|
|
|
1253
1280
|
|
|
1254
1281
|
# ─── Connection Verification ─────────────────────────────────────────────────
|
|
1255
1282
|
|
|
1283
|
+
#: Windows surfaces a hard access violation (STATUS_ACCESS_VIOLATION) as a
|
|
1284
|
+
#: process exit code. Both spellings appear in the wild: the unsigned value and
|
|
1285
|
+
#: the signed reading of the same 32 bits. The interpreter dies inside the
|
|
1286
|
+
#: native library, so there is no traceback and no stderr to report — without
|
|
1287
|
+
#: this translation the probe can only say "exited with code 3221225477",
|
|
1288
|
+
#: which is the shape issue #158 was reported as.
|
|
1289
|
+
WINDOWS_ACCESS_VIOLATION_CODES = (3221225477, -1073741819)
|
|
1290
|
+
|
|
1291
|
+
|
|
1292
|
+
def _version_parts(version):
|
|
1293
|
+
"""A `(major, minor, ...)` int tuple, or None if it cannot be read.
|
|
1294
|
+
|
|
1295
|
+
`is_abi_risk_python_version` unpacks `version[:2]`, so handing it the string
|
|
1296
|
+
"3.13.3" yields `("3", ".")` and a quiet False — the interpreter most likely
|
|
1297
|
+
to have caused the crash would be reported as not at risk. Normalizing here
|
|
1298
|
+
means a caller cannot make that mistake by passing the obvious thing.
|
|
1299
|
+
"""
|
|
1300
|
+
if version is None:
|
|
1301
|
+
return None
|
|
1302
|
+
if isinstance(version, str):
|
|
1303
|
+
try:
|
|
1304
|
+
return tuple(int(part) for part in version.split(".")[:3])
|
|
1305
|
+
except ValueError:
|
|
1306
|
+
return None
|
|
1307
|
+
try:
|
|
1308
|
+
return tuple(int(part) for part in tuple(version)[:3])
|
|
1309
|
+
except (TypeError, ValueError):
|
|
1310
|
+
return None
|
|
1311
|
+
|
|
1312
|
+
|
|
1313
|
+
def access_violation_message(returncode, version=None):
|
|
1314
|
+
"""Explain an access-violation exit, naming the ABI theory when it fits."""
|
|
1315
|
+
text = (
|
|
1316
|
+
"Python crashed with an access violation (0xC0000005) while loading "
|
|
1317
|
+
"Resolve's scripting library — no traceback is possible, the process "
|
|
1318
|
+
"was terminated by the OS."
|
|
1319
|
+
)
|
|
1320
|
+
parts = _version_parts(version)
|
|
1321
|
+
if parts is None or is_abi_risk_python_version(parts):
|
|
1322
|
+
text += (
|
|
1323
|
+
" This is the signature of a C ABI mismatch: recreate the venv on "
|
|
1324
|
+
"Python 3.10-3.12 (e.g. DAVINCI_RESOLVE_MCP_PYTHON=...\\python3.12.exe)."
|
|
1325
|
+
)
|
|
1326
|
+
else:
|
|
1327
|
+
text += " Check that RESOLVE_SCRIPT_LIB names the library from your Resolve install."
|
|
1328
|
+
return text
|
|
1329
|
+
|
|
1330
|
+
|
|
1256
1331
|
def verify_resolve_connection(python_path, api_path, lib_path):
|
|
1257
1332
|
"""Try to import DaVinciResolveScript and connect."""
|
|
1258
1333
|
if not api_path:
|
|
@@ -1311,6 +1386,12 @@ def verify_resolve_connection(python_path, api_path, lib_path):
|
|
|
1311
1386
|
elif output.startswith("IMPORTED_OK:"):
|
|
1312
1387
|
return True, "API module loaded (Resolve not running)"
|
|
1313
1388
|
else:
|
|
1389
|
+
if result.returncode in WINDOWS_ACCESS_VIOLATION_CODES:
|
|
1390
|
+
try:
|
|
1391
|
+
version = _version_for_python(python_path)
|
|
1392
|
+
except Exception:
|
|
1393
|
+
version = None
|
|
1394
|
+
return False, access_violation_message(result.returncode, version)
|
|
1314
1395
|
if output:
|
|
1315
1396
|
return False, output
|
|
1316
1397
|
return False, f"Process exited with code {result.returncode}"
|
|
@@ -2219,7 +2300,10 @@ def main():
|
|
|
2219
2300
|
" Point RESOLVE_SCRIPT_LIB at the fusionscript library inside your "
|
|
2220
2301
|
"Resolve install, or start Resolve and re-run setup."
|
|
2221
2302
|
)
|
|
2222
|
-
if py_abi_risk:
|
|
2303
|
+
if py_abi_risk and "0xC0000005" not in message:
|
|
2304
|
+
# The access-violation message already carries this remedy, and
|
|
2305
|
+
# states it as a diagnosis rather than a maybe. Do not follow it
|
|
2306
|
+
# with a weaker restatement of itself.
|
|
2223
2307
|
print(
|
|
2224
2308
|
f" On Python 3.13+ this may be an ABI mismatch with Resolve's "
|
|
2225
2309
|
f"scripting library — try Python 3.10-3.12 if it persists."
|
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
|
|
package/scripts/doctor.py
CHANGED
|
@@ -95,13 +95,64 @@ def _platform_key(platform: str | None = None) -> str:
|
|
|
95
95
|
return "linux"
|
|
96
96
|
|
|
97
97
|
|
|
98
|
+
def _discovered_default(kind: str) -> str | None:
|
|
99
|
+
"""Ask the runtime helpers where Resolve actually is.
|
|
100
|
+
|
|
101
|
+
The candidate table above is a table of *conventional* locations. Resolve is
|
|
102
|
+
routinely installed somewhere else — a second drive is the common case,
|
|
103
|
+
because the application and its caches are large (issue #158 reported
|
|
104
|
+
`D:\\Programs\\DaVinci Resolve`). install.py already looks past the table
|
|
105
|
+
via these helpers; doctor did not, so it reported FAIL on installs the
|
|
106
|
+
installer had just configured correctly. That disagreement between two tools
|
|
107
|
+
describing one machine is the same failure shape as issue #106.
|
|
108
|
+
|
|
109
|
+
Only consulted when no candidate exists, and only for the two kinds the
|
|
110
|
+
helpers can answer. `api` has no discovery helper, so a non-default install
|
|
111
|
+
can still miss there — worth knowing when reading a report.
|
|
112
|
+
"""
|
|
113
|
+
if str(REPO) not in sys.path:
|
|
114
|
+
# doctor is run standalone (`python scripts/doctor.py`) as often as it is
|
|
115
|
+
# run through the launcher, and only the latter puts the repo on the path.
|
|
116
|
+
sys.path.insert(0, str(REPO))
|
|
117
|
+
|
|
118
|
+
if kind == "lib":
|
|
119
|
+
try:
|
|
120
|
+
from src.utils.platform import discover_scripting_lib
|
|
121
|
+
except Exception:
|
|
122
|
+
return None
|
|
123
|
+
try:
|
|
124
|
+
discovered = discover_scripting_lib()
|
|
125
|
+
except Exception:
|
|
126
|
+
return None
|
|
127
|
+
return str(discovered) if discovered and Path(discovered).exists() else None
|
|
128
|
+
|
|
129
|
+
if kind == "app":
|
|
130
|
+
try:
|
|
131
|
+
from src.utils.resolve_runtime import _executable_from_line, resolve_processes
|
|
132
|
+
except Exception:
|
|
133
|
+
return None
|
|
134
|
+
try:
|
|
135
|
+
processes = resolve_processes() or []
|
|
136
|
+
except Exception:
|
|
137
|
+
return None
|
|
138
|
+
for line in processes:
|
|
139
|
+
executable = _executable_from_line(line)
|
|
140
|
+
if executable and Path(executable).exists():
|
|
141
|
+
return str(executable)
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
|
|
98
145
|
def _resolve_default(kind: str, platform: str | None = None) -> str:
|
|
99
|
-
"""First candidate of `kind` that exists, else the first
|
|
100
|
-
names the canonical location rather than an
|
|
146
|
+
"""First candidate of `kind` that exists, then discovery, else the first
|
|
147
|
+
candidate (so the FAIL line names the canonical location rather than an
|
|
148
|
+
arbitrary miss)."""
|
|
101
149
|
candidates = _RESOLVE_PATH_CANDIDATES[_platform_key(platform)][kind]
|
|
102
150
|
for candidate in candidates:
|
|
103
151
|
if Path(candidate).exists():
|
|
104
152
|
return candidate
|
|
153
|
+
discovered = _discovered_default(kind)
|
|
154
|
+
if discovered:
|
|
155
|
+
return discovered
|
|
105
156
|
return candidates[0]
|
|
106
157
|
|
|
107
158
|
|
|
@@ -170,11 +221,27 @@ def check(results: list[dict[str, str]], status: str, name: str, detail: str) ->
|
|
|
170
221
|
results.append({"status": status, "name": name, "detail": detail})
|
|
171
222
|
|
|
172
223
|
|
|
224
|
+
def _normalize_separators(text: str) -> str:
|
|
225
|
+
r"""Collapse every run of backslashes to a single forward slash.
|
|
226
|
+
|
|
227
|
+
A Windows path does not survive a literal substring test against the file it
|
|
228
|
+
was written into: JSON escapes `C:\Users\x` as `C:\\Users\\x`, and TOML
|
|
229
|
+
may keep it single-escaped or write a literal string. doctor compared the
|
|
230
|
+
unescaped path against the raw file text and so reported the entry missing
|
|
231
|
+
on configs it had itself just written (issue #158).
|
|
232
|
+
|
|
233
|
+
Collapsing runs — rather than only the doubled form — is what makes both
|
|
234
|
+
spellings compare equal, and normalizing the needle the same way keeps the
|
|
235
|
+
comparison symmetric.
|
|
236
|
+
"""
|
|
237
|
+
return re.sub(r"\\+", "/", text)
|
|
238
|
+
|
|
239
|
+
|
|
173
240
|
def file_contains(path: Path, needles: list[str]) -> tuple[bool, str]:
|
|
174
241
|
if not path.exists():
|
|
175
242
|
return False, "missing"
|
|
176
|
-
text = path.read_text(errors="replace")
|
|
177
|
-
missing = [needle for needle in needles if needle not in text]
|
|
243
|
+
text = _normalize_separators(path.read_text(errors="replace"))
|
|
244
|
+
missing = [needle for needle in needles if _normalize_separators(needle) not in text]
|
|
178
245
|
if missing:
|
|
179
246
|
return False, "missing: " + ", ".join(missing)
|
|
180
247
|
return True, "contains davinci-resolve MCP entry"
|
|
@@ -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()
|