davinci-resolve-mcp 2.36.1 → 2.37.1
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 +51 -0
- package/README.md +3 -3
- package/docs/SKILL.md +11 -2
- package/docs/kernels/timeline-conform-interchange-kernel.md +7 -4
- package/install.py +1 -1
- package/package.json +1 -1
- package/scripts/doctor.py +227 -0
- package/src/granular/common.py +1 -1
- package/src/server.py +302 -34
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,57 @@
|
|
|
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.37.1
|
|
6
|
+
|
|
7
|
+
Test-suite hygiene — no server behavior changed.
|
|
8
|
+
|
|
9
|
+
- **Fixed** the legacy live-harness scripts (`test_all_tools`, `test_phase2`–`5`)
|
|
10
|
+
exiting at import when Resolve is unavailable, which crashed pytest
|
|
11
|
+
collection and surfaced as five loader errors under unittest discovery.
|
|
12
|
+
They now skip cleanly under both runners and keep the hard-exit behavior
|
|
13
|
+
when run as standalone scripts. (Adapted from a contribution by @diesdaas.)
|
|
14
|
+
- **Fixed** pytest mis-collecting `test_resolve20_api.py`'s internal `test()`
|
|
15
|
+
helper (renamed to `run_live_check()`), and made the batch-CLI synthetic-job
|
|
16
|
+
test independent of which transcription backends the host has installed.
|
|
17
|
+
- **CI**: the npm publish workflow no longer reports failure when the registry
|
|
18
|
+
accepted the publish but npm's retried request hit a consumed OIDC token;
|
|
19
|
+
it now verifies the published tarball shasum before failing. Runner actions
|
|
20
|
+
bumped to their Node 24 majors.
|
|
21
|
+
|
|
22
|
+
## What's New in v2.37.0
|
|
23
|
+
|
|
24
|
+
Render format-id fix, offline-media diagnosis, and a setup doctor.
|
|
25
|
+
|
|
26
|
+
- **Fixed** render helpers passing the display name from `GetRenderFormats()`
|
|
27
|
+
(e.g. `QuickTime`) into `GetRenderCodecs`, `GetRenderResolutions`, and
|
|
28
|
+
`SetCurrentRenderFormatAndCodec`, which expect the format id (e.g. `mov`).
|
|
29
|
+
`GetRenderCodecs("QuickTime")` returned empty, so `probe_render_matrix`
|
|
30
|
+
reported no QuickTime codecs and `prepare_render_job` could not target
|
|
31
|
+
ProRes (e.g. `ProRes422LT` proxy renders). Display names and format ids are
|
|
32
|
+
now both accepted as input and normalized to the id for Resolve API calls;
|
|
33
|
+
`probe_render_matrix` rows include `format_id`. Closes #59.
|
|
34
|
+
- **Fixed** the destructive confirm-token gate referencing a stale function
|
|
35
|
+
name, which made it silently ignore the `destructive.require_confirm_token`
|
|
36
|
+
preference and always fall back to the default.
|
|
37
|
+
- **Added** a `diagnosis` block to `timeline.detect_missing_media`:
|
|
38
|
+
deduplicated missing Media Pool items, volume/folder mounted checks, a
|
|
39
|
+
primary cause (`volume_not_mounted` / `folder_not_found` /
|
|
40
|
+
`files_missing_or_renamed`), and a recommended next step. Optional
|
|
41
|
+
`sanitized`/`sanitize_paths` redacts raw media paths.
|
|
42
|
+
- **Added** bounds to `timeline.build_relink_plan`: skips the broad scan by
|
|
43
|
+
default when a source volume (e.g. a camera card) is not mounted, dedupes
|
|
44
|
+
missing basenames, and caps the search with `max_depth`, `max_seconds`
|
|
45
|
+
(default 20s), and `max_files_scanned` (default 50k), reporting per-candidate
|
|
46
|
+
scan stats.
|
|
47
|
+
- **Fixed** `media_analysis` accepting `vision: true/false` boolean shorthand
|
|
48
|
+
(now normalized to the full options dict) and `timeline_markers.get_thumbnail`
|
|
49
|
+
returning a structured error instead of raw `None` when no thumbnail is
|
|
50
|
+
available.
|
|
51
|
+
- **Added** `scripts/doctor.py`, a read-only setup diagnostic that checks the
|
|
52
|
+
checkout, Python, Resolve app/scripting paths, and MCP client configs, and
|
|
53
|
+
probes the scripting bridge end to end with OK/WARN/FAIL output (`--json`
|
|
54
|
+
supported).
|
|
55
|
+
|
|
5
56
|
## What's New in v2.36.1
|
|
6
57
|
|
|
7
58
|
Bug fix — restore the `fusion_comp` MCP tool.
|
package/README.md
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# DaVinci Resolve MCP Server
|
|
2
2
|
|
|
3
|
-
[](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
|
|
4
4
|
[](https://www.npmjs.com/package/davinci-resolve-mcp)
|
|
5
5
|
[](docs/reference/api-coverage.md)
|
|
6
|
-
[-blue.svg)](#server-modes)
|
|
7
7
|
[](docs/reference/api-coverage.md#test-results)
|
|
8
8
|
[](https://www.blackmagicdesign.com/products/davinciresolve)
|
|
9
9
|
[](https://www.python.org/downloads/)
|
|
@@ -49,7 +49,7 @@ The command starts a localhost server and opens the control panel in your browse
|
|
|
49
49
|
|
|
50
50
|
| Mode | Entry point | Tools | Best for |
|
|
51
51
|
|------|-------------|-------|----------|
|
|
52
|
-
| Compound | `src/server.py` |
|
|
52
|
+
| Compound | `src/server.py` | 33 | Default mode for most assistants. Related Resolve operations are grouped behind action parameters to keep context usage low. |
|
|
53
53
|
| Full / granular | `src/server.py --full` or `src/resolve_mcp_server.py` | 341 | Power users who want one MCP tool per Resolve API method. |
|
|
54
54
|
|
|
55
55
|
The compound server is recommended unless you specifically need the granular one-tool-per-method surface.
|
package/docs/SKILL.md
CHANGED
|
@@ -807,10 +807,19 @@ structure, interchange, comparison, missing-media, and relink-planning helpers:
|
|
|
807
807
|
- `import_timeline_checked(path, options?, timeline_name?, import_source_clips?, require_temp_path?, dry_run?)`
|
|
808
808
|
- `compare_timelines(right_timeline_id?|right_timeline_index?|left_snapshot?, right_snapshot?)`
|
|
809
809
|
- `probe_interchange_roundtrip(format?, output_dir?, cleanup_imported?)`
|
|
810
|
-
- `detect_missing_media`
|
|
811
|
-
- `build_relink_plan(search_roots)`
|
|
810
|
+
- `detect_missing_media(sanitized?|sanitize_paths?, omit_raw_paths?)`
|
|
811
|
+
- `build_relink_plan(search_roots, max_depth?, max_seconds?, max_files_scanned?, skip_search_when_volume_missing?, sanitized?)`
|
|
812
812
|
- `conform_boundary_report`
|
|
813
813
|
|
|
814
|
+
For offline media, prefer the sanitized readback first:
|
|
815
|
+
`timeline(action="detect_missing_media", params={"sanitized": true})`. The
|
|
816
|
+
response includes a `diagnosis` block with deduplicated Media Pool items,
|
|
817
|
+
missing volume roots, sample basenames, and a recommended next step. If a source
|
|
818
|
+
volume such as a camera card is not mounted, `build_relink_plan` skips broad
|
|
819
|
+
search by default and reports `skip_reason="missing_source_volume_not_mounted"`;
|
|
820
|
+
mount the volume or pass `skip_search_when_volume_missing=false` only when a
|
|
821
|
+
bounded scan of approved roots is intentional.
|
|
822
|
+
|
|
814
823
|
Audio / Fairlight kernel actions (v2.14.0+) add live-tested audio state,
|
|
815
824
|
mapping, voice-isolation, sync, transcription, subtitle, and Fairlight boundary
|
|
816
825
|
helpers:
|
|
@@ -35,8 +35,8 @@ All actions are exposed through `timeline`.
|
|
|
35
35
|
| `import_timeline_checked` | Guard timeline imports from temp locations and normalize import options. |
|
|
36
36
|
| `compare_timelines` | Compare current timeline to another timeline or two supplied snapshots. |
|
|
37
37
|
| `probe_interchange_roundtrip` | Export, import, compare, and optionally delete the imported timeline. |
|
|
38
|
-
| `detect_missing_media` | Detect missing/offline media using Resolve status fields and file-path existence. |
|
|
39
|
-
| `build_relink_plan` | Read-only search-root scan for relink candidates by missing file basename. |
|
|
38
|
+
| `detect_missing_media` | Detect missing/offline media using Resolve status fields and file-path existence, with a sanitized diagnosis block for mounted-volume/folder/file failure modes. |
|
|
39
|
+
| `build_relink_plan` | Read-only, bounded search-root scan for relink candidates by missing file basename. Skips broad scans by default when the source volume is not mounted. |
|
|
40
40
|
| `conform_boundary_report` | Return capabilities, timeline structure, gaps/overlaps, source ranges, and missing-media summary. |
|
|
41
41
|
|
|
42
42
|
## Interchange Matrix
|
|
@@ -74,8 +74,11 @@ Supported aliases include `aaf`, `drt`, `edl`, `edl_cdl`, `edl_sdl`,
|
|
|
74
74
|
relationships.
|
|
75
75
|
- The public API exposes timeline items, source ranges, markers, and some media
|
|
76
76
|
references, but not full transition/effect/retime semantics for every format.
|
|
77
|
-
- `build_relink_plan` is intentionally read-only.
|
|
78
|
-
|
|
77
|
+
- `build_relink_plan` is intentionally read-only. It deduplicates missing
|
|
78
|
+
basenames, supports `max_depth`, `max_seconds`, and `max_files_scanned`, and
|
|
79
|
+
skips broad scans by default when a source volume such as a camera card is not
|
|
80
|
+
mounted. Execute relinks through `media_pool.safe_relink` only with synthetic
|
|
81
|
+
or explicitly approved paths.
|
|
79
82
|
- Missing-media status fields vary by Resolve build. The kernel combines status
|
|
80
83
|
text with local file existence when a file path is available.
|
|
81
84
|
- Export and import helpers require temp paths by default because they write
|
package/install.py
CHANGED
|
@@ -35,7 +35,7 @@ from src.utils.update_check import (
|
|
|
35
35
|
|
|
36
36
|
# ─── Version ──────────────────────────────────────────────────────────────────
|
|
37
37
|
|
|
38
|
-
VERSION = "2.
|
|
38
|
+
VERSION = "2.37.1"
|
|
39
39
|
# Only hard floor: mcp[cli] requires Python 3.10+. There is no upper bound —
|
|
40
40
|
# Resolve's scripting bridge loads into newer interpreters on recent builds
|
|
41
41
|
# (Python 3.14 verified against Resolve Studio 20.3.2). Older Resolve builds
|
package/package.json
CHANGED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Read-only diagnostics for a local DaVinci Resolve MCP setup.
|
|
3
|
+
|
|
4
|
+
Checks the checkout, Python interpreter, Resolve app and scripting-API paths,
|
|
5
|
+
MCP client configs, and probes the DaVinciResolveScript bridge without
|
|
6
|
+
mutating anything. Prints OK/WARN/FAIL lines (or JSON with --json) and exits
|
|
7
|
+
nonzero only when a FAIL is present.
|
|
8
|
+
|
|
9
|
+
Run: `python3 scripts/doctor.py`
|
|
10
|
+
|
|
11
|
+
Overridable via environment: DAVINCI_MCP_REPO, DAVINCI_MCP_PYTHON,
|
|
12
|
+
CODEX_HOME, CLAUDE_DESKTOP_CONFIG, RESOLVE_APP, RESOLVE_SCRIPT_API,
|
|
13
|
+
RESOLVE_SCRIPT_MODULES, RESOLVE_SCRIPT_LIB.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import re
|
|
22
|
+
import subprocess
|
|
23
|
+
import sys
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def find_repo_root() -> Path:
|
|
29
|
+
env_repo = os.environ.get("DAVINCI_MCP_REPO")
|
|
30
|
+
if env_repo:
|
|
31
|
+
return Path(env_repo).expanduser().resolve()
|
|
32
|
+
current = Path(__file__).resolve()
|
|
33
|
+
for candidate in [current, *current.parents]:
|
|
34
|
+
if (candidate / "src" / "server.py").exists():
|
|
35
|
+
return candidate
|
|
36
|
+
return current.parents[1]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
REPO = find_repo_root()
|
|
40
|
+
PYTHON = Path(os.environ.get("DAVINCI_MCP_PYTHON", REPO / "venv" / "bin" / "python")).expanduser()
|
|
41
|
+
if not PYTHON.exists():
|
|
42
|
+
PYTHON = REPO / ".venv" / "bin" / "python"
|
|
43
|
+
if not PYTHON.exists():
|
|
44
|
+
PYTHON = Path(sys.executable)
|
|
45
|
+
SERVER = REPO / "src" / "server.py"
|
|
46
|
+
CODEX_HOME = Path(os.environ.get("CODEX_HOME", "~/.codex")).expanduser()
|
|
47
|
+
CODEX_CONFIG = CODEX_HOME / "config.toml"
|
|
48
|
+
CLAUDE_CONFIG = Path(
|
|
49
|
+
os.environ.get(
|
|
50
|
+
"CLAUDE_DESKTOP_CONFIG",
|
|
51
|
+
"~/Library/Application Support/Claude/claude_desktop_config.json",
|
|
52
|
+
)
|
|
53
|
+
).expanduser()
|
|
54
|
+
RESOLVE_APP = Path(os.environ.get("RESOLVE_APP", "/Applications/DaVinci Resolve/DaVinci Resolve.app"))
|
|
55
|
+
RESOLVE_API = Path(
|
|
56
|
+
os.environ.get(
|
|
57
|
+
"RESOLVE_SCRIPT_API",
|
|
58
|
+
"/Library/Application Support/Blackmagic Design/DaVinci Resolve/Developer/Scripting",
|
|
59
|
+
)
|
|
60
|
+
)
|
|
61
|
+
RESOLVE_MODULES = Path(os.environ.get("RESOLVE_SCRIPT_MODULES", RESOLVE_API / "Modules"))
|
|
62
|
+
RESOLVE_LIB = Path(
|
|
63
|
+
os.environ.get(
|
|
64
|
+
"RESOLVE_SCRIPT_LIB",
|
|
65
|
+
"/Applications/DaVinci Resolve/DaVinci Resolve.app/Contents/Libraries/Fusion/fusionscript.so",
|
|
66
|
+
)
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def run(cmd: list[str], timeout: int = 12) -> dict[str, Any]:
|
|
71
|
+
try:
|
|
72
|
+
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
|
|
73
|
+
return {
|
|
74
|
+
"ok": proc.returncode == 0,
|
|
75
|
+
"returncode": proc.returncode,
|
|
76
|
+
"stdout": proc.stdout.strip(),
|
|
77
|
+
"stderr": proc.stderr.strip(),
|
|
78
|
+
}
|
|
79
|
+
except Exception as exc:
|
|
80
|
+
return {"ok": False, "returncode": None, "stdout": "", "stderr": str(exc)}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def run_in_repo(cmd: list[str], timeout: int = 12) -> dict[str, Any]:
|
|
84
|
+
try:
|
|
85
|
+
proc = subprocess.run(cmd, cwd=REPO, capture_output=True, text=True, timeout=timeout)
|
|
86
|
+
return {
|
|
87
|
+
"ok": proc.returncode == 0,
|
|
88
|
+
"returncode": proc.returncode,
|
|
89
|
+
"stdout": proc.stdout.strip(),
|
|
90
|
+
"stderr": proc.stderr.strip(),
|
|
91
|
+
}
|
|
92
|
+
except Exception as exc:
|
|
93
|
+
return {"ok": False, "returncode": None, "stdout": "", "stderr": str(exc)}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def check(results: list[dict[str, str]], status: str, name: str, detail: str) -> None:
|
|
97
|
+
results.append({"status": status, "name": name, "detail": detail})
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def file_contains(path: Path, needles: list[str]) -> tuple[bool, str]:
|
|
101
|
+
if not path.exists():
|
|
102
|
+
return False, "missing"
|
|
103
|
+
text = path.read_text(errors="replace")
|
|
104
|
+
missing = [needle for needle in needles if needle not in text]
|
|
105
|
+
if missing:
|
|
106
|
+
return False, "missing: " + ", ".join(missing)
|
|
107
|
+
return True, "contains davinci-resolve MCP entry"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def version_from_server() -> str:
|
|
111
|
+
if not SERVER.exists():
|
|
112
|
+
return "unknown"
|
|
113
|
+
match = re.search(r'^VERSION\s*=\s*"([^"]+)"', SERVER.read_text(errors="replace"), re.M)
|
|
114
|
+
return match.group(1) if match else "unknown"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def git_summary() -> str:
|
|
118
|
+
if not (REPO / ".git").exists():
|
|
119
|
+
return "not a git checkout"
|
|
120
|
+
status = run_in_repo(["git", "status", "--short", "--branch"], timeout=8)
|
|
121
|
+
return status["stdout"] or status["stderr"] or "git status produced no output"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def git_head() -> str:
|
|
125
|
+
if not (REPO / ".git").exists():
|
|
126
|
+
return "not a git checkout"
|
|
127
|
+
head = run_in_repo(["git", "describe", "--tags", "--always", "--dirty"], timeout=8)
|
|
128
|
+
return head["stdout"] or head["stderr"] or "git describe produced no output"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def resolve_probe() -> dict[str, Any]:
|
|
132
|
+
if not PYTHON.exists():
|
|
133
|
+
return {"import_ok": False, "error": f"{PYTHON} is missing"}
|
|
134
|
+
|
|
135
|
+
code = f"""
|
|
136
|
+
import json
|
|
137
|
+
import sys
|
|
138
|
+
sys.path.insert(0, {str(RESOLVE_MODULES)!r})
|
|
139
|
+
try:
|
|
140
|
+
import DaVinciResolveScript as dvr
|
|
141
|
+
resolve = dvr.scriptapp("Resolve")
|
|
142
|
+
payload = {{
|
|
143
|
+
"import_ok": True,
|
|
144
|
+
"module": getattr(dvr, "__file__", None),
|
|
145
|
+
"resolve_connected": bool(resolve),
|
|
146
|
+
"product": resolve.GetProductName() if resolve else None,
|
|
147
|
+
"version": resolve.GetVersionString() if resolve else None,
|
|
148
|
+
}}
|
|
149
|
+
except Exception as exc:
|
|
150
|
+
payload = {{"import_ok": False, "error": repr(exc)}}
|
|
151
|
+
print(json.dumps(payload))
|
|
152
|
+
"""
|
|
153
|
+
env = {
|
|
154
|
+
**os.environ,
|
|
155
|
+
"RESOLVE_SCRIPT_API": str(RESOLVE_API),
|
|
156
|
+
"RESOLVE_SCRIPT_LIB": str(RESOLVE_LIB),
|
|
157
|
+
"PYTHONPATH": str(RESOLVE_MODULES),
|
|
158
|
+
}
|
|
159
|
+
proc = subprocess.run([str(PYTHON), "-c", code], capture_output=True, text=True, timeout=12, env=env)
|
|
160
|
+
output = (proc.stdout or proc.stderr).strip()
|
|
161
|
+
try:
|
|
162
|
+
payload = json.loads(output)
|
|
163
|
+
except json.JSONDecodeError:
|
|
164
|
+
payload = {"import_ok": False, "error": output or f"probe exited {proc.returncode}"}
|
|
165
|
+
payload["returncode"] = proc.returncode
|
|
166
|
+
return payload
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def collect() -> list[dict[str, str]]:
|
|
170
|
+
results: list[dict[str, str]] = []
|
|
171
|
+
|
|
172
|
+
check(results, "OK" if REPO.exists() else "FAIL", "MCP checkout", str(REPO))
|
|
173
|
+
check(results, "OK" if SERVER.exists() else "FAIL", "Server entrypoint", str(SERVER))
|
|
174
|
+
check(results, "OK" if PYTHON.exists() else "FAIL", "Python", str(PYTHON))
|
|
175
|
+
check(results, "OK" if RESOLVE_APP.exists() else "FAIL", "Resolve app", str(RESOLVE_APP))
|
|
176
|
+
check(results, "OK" if RESOLVE_API.exists() else "FAIL", "Resolve scripting API", str(RESOLVE_API))
|
|
177
|
+
check(results, "OK" if RESOLVE_MODULES.exists() else "FAIL", "Resolve scripting modules", str(RESOLVE_MODULES))
|
|
178
|
+
check(results, "OK" if RESOLVE_LIB.exists() else "FAIL", "Resolve scripting library", str(RESOLVE_LIB))
|
|
179
|
+
|
|
180
|
+
needles = [str(SERVER)]
|
|
181
|
+
ok, detail = file_contains(CODEX_CONFIG, needles)
|
|
182
|
+
check(results, "OK" if ok else "WARN", "Codex MCP config", f"{CODEX_CONFIG}: {detail}")
|
|
183
|
+
ok, detail = file_contains(CLAUDE_CONFIG, needles)
|
|
184
|
+
check(results, "OK" if ok else "WARN", "Claude Desktop MCP config", f"{CLAUDE_CONFIG}: {detail}")
|
|
185
|
+
|
|
186
|
+
pyver = run([str(PYTHON), "--version"]) if PYTHON.exists() else {"ok": False, "stdout": "", "stderr": "missing"}
|
|
187
|
+
check(results, "OK" if pyver["ok"] else "FAIL", "Python version", pyver["stdout"] or pyver["stderr"])
|
|
188
|
+
|
|
189
|
+
probe = resolve_probe()
|
|
190
|
+
if probe.get("import_ok"):
|
|
191
|
+
check(results, "OK", "DaVinciResolveScript import", str(probe.get("module")))
|
|
192
|
+
if probe.get("resolve_connected"):
|
|
193
|
+
detail = f"{probe.get('product')} {probe.get('version')}"
|
|
194
|
+
check(results, "OK", "Resolve scripting connection", detail)
|
|
195
|
+
else:
|
|
196
|
+
check(
|
|
197
|
+
results,
|
|
198
|
+
"WARN",
|
|
199
|
+
"Resolve scripting connection",
|
|
200
|
+
'Module import worked, but scriptapp("Resolve") returned no object. Open DaVinci Resolve Studio, set Preferences > General > External scripting using = Local, and restart Resolve.',
|
|
201
|
+
)
|
|
202
|
+
else:
|
|
203
|
+
check(results, "FAIL", "DaVinciResolveScript import", str(probe.get("error")))
|
|
204
|
+
|
|
205
|
+
check(results, "OK", "MCP server version", version_from_server())
|
|
206
|
+
check(results, "OK", "MCP git head", git_head())
|
|
207
|
+
check(results, "OK", "Git status", git_summary())
|
|
208
|
+
return results
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def main() -> int:
|
|
212
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
213
|
+
parser.add_argument("--json", action="store_true", help="Print machine-readable JSON")
|
|
214
|
+
args = parser.parse_args()
|
|
215
|
+
|
|
216
|
+
results = collect()
|
|
217
|
+
if args.json:
|
|
218
|
+
print(json.dumps({"checks": results}, indent=2))
|
|
219
|
+
else:
|
|
220
|
+
for item in results:
|
|
221
|
+
print(f"[{item['status']}] {item['name']}: {item['detail']}")
|
|
222
|
+
|
|
223
|
+
return 1 if any(item["status"] == "FAIL" for item in results) else 0
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
if __name__ == "__main__":
|
|
227
|
+
raise SystemExit(main())
|
package/src/granular/common.py
CHANGED
|
@@ -80,7 +80,7 @@ if not logging.getLogger().handlers:
|
|
|
80
80
|
handlers=[logging.StreamHandler()],
|
|
81
81
|
)
|
|
82
82
|
|
|
83
|
-
VERSION = "2.
|
|
83
|
+
VERSION = "2.37.1"
|
|
84
84
|
logger = logging.getLogger("davinci-resolve-mcp")
|
|
85
85
|
logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
|
|
86
86
|
logger.info(f"Detected platform: {get_platform()}")
|
package/src/server.py
CHANGED
|
@@ -11,7 +11,7 @@ Usage:
|
|
|
11
11
|
python src/server.py --full # Start the 341-tool granular server instead
|
|
12
12
|
"""
|
|
13
13
|
|
|
14
|
-
VERSION = "2.
|
|
14
|
+
VERSION = "2.37.1"
|
|
15
15
|
|
|
16
16
|
import base64
|
|
17
17
|
import os
|
|
@@ -973,7 +973,7 @@ def _confirm_token_gc():
|
|
|
973
973
|
def _confirm_token_required() -> bool:
|
|
974
974
|
"""Honor setup default destructive.require_confirm_token (default True)."""
|
|
975
975
|
try:
|
|
976
|
-
prefs =
|
|
976
|
+
prefs = _read_media_analysis_preferences() if "_read_media_analysis_preferences" in globals() else {}
|
|
977
977
|
except Exception:
|
|
978
978
|
prefs = {}
|
|
979
979
|
destructive = prefs.get("destructive") if isinstance(prefs.get("destructive"), dict) else {}
|
|
@@ -5185,6 +5185,123 @@ def _probe_interchange_roundtrip(proj, mp, tl, p: Dict[str, Any]):
|
|
|
5185
5185
|
}
|
|
5186
5186
|
|
|
5187
5187
|
|
|
5188
|
+
def _sanitize_media_path(path: Any, *, keep_filename: bool = True) -> Optional[str]:
|
|
5189
|
+
if not path:
|
|
5190
|
+
return None
|
|
5191
|
+
text = str(path)
|
|
5192
|
+
filename = os.path.basename(text.rstrip(os.sep))
|
|
5193
|
+
parts = [part for part in text.split(os.sep) if part]
|
|
5194
|
+
if text.startswith("/Volumes/") and len(parts) >= 2:
|
|
5195
|
+
root = f"/Volumes/{parts[1]}"
|
|
5196
|
+
return f"{root}/.../{filename}" if keep_filename and filename else f"{root}/..."
|
|
5197
|
+
if text.startswith(os.sep):
|
|
5198
|
+
return f"/.../{filename}" if keep_filename and filename else "/..."
|
|
5199
|
+
return f".../{filename}" if keep_filename and filename else "..."
|
|
5200
|
+
|
|
5201
|
+
|
|
5202
|
+
def _media_path_volume_root(path: Any) -> Optional[str]:
|
|
5203
|
+
if not path:
|
|
5204
|
+
return None
|
|
5205
|
+
text = str(path)
|
|
5206
|
+
parts = [part for part in text.split(os.sep) if part]
|
|
5207
|
+
if text.startswith("/Volumes/") and len(parts) >= 2:
|
|
5208
|
+
return f"/Volumes/{parts[1]}"
|
|
5209
|
+
if text.startswith(os.sep) and parts:
|
|
5210
|
+
return os.sep + parts[0]
|
|
5211
|
+
return None
|
|
5212
|
+
|
|
5213
|
+
|
|
5214
|
+
def _missing_media_diagnosis(missing_rows: List[Dict[str, Any]]) -> Dict[str, Any]:
|
|
5215
|
+
by_media_pool_item: Dict[str, Dict[str, Any]] = {}
|
|
5216
|
+
missing_volumes: Dict[str, Dict[str, Any]] = {}
|
|
5217
|
+
missing_folders: Dict[str, Dict[str, Any]] = {}
|
|
5218
|
+
|
|
5219
|
+
for row in missing_rows:
|
|
5220
|
+
file_path = row.get("file_path")
|
|
5221
|
+
media_pool_item_id = row.get("media_pool_item_id") or file_path or row.get("name")
|
|
5222
|
+
basename = os.path.basename(str(file_path or row.get("media_pool_item_name") or row.get("name") or ""))
|
|
5223
|
+
item = by_media_pool_item.setdefault(
|
|
5224
|
+
str(media_pool_item_id),
|
|
5225
|
+
{
|
|
5226
|
+
"media_pool_item_id": row.get("media_pool_item_id"),
|
|
5227
|
+
"media_pool_item_name": row.get("media_pool_item_name") or row.get("name"),
|
|
5228
|
+
"wanted_basename": basename,
|
|
5229
|
+
"file_path_sanitized": _sanitize_media_path(file_path),
|
|
5230
|
+
"volume_root_sanitized": _sanitize_media_path(_media_path_volume_root(file_path), keep_filename=False),
|
|
5231
|
+
"timeline_occurrence_count": 0,
|
|
5232
|
+
"track_refs": [],
|
|
5233
|
+
},
|
|
5234
|
+
)
|
|
5235
|
+
item["timeline_occurrence_count"] += 1
|
|
5236
|
+
item["track_refs"].append(
|
|
5237
|
+
{
|
|
5238
|
+
"track_type": row.get("track_type"),
|
|
5239
|
+
"track_index": row.get("track_index"),
|
|
5240
|
+
"timeline_item_id": row.get("timeline_item_id"),
|
|
5241
|
+
}
|
|
5242
|
+
)
|
|
5243
|
+
|
|
5244
|
+
volume_root = _media_path_volume_root(file_path)
|
|
5245
|
+
if volume_root:
|
|
5246
|
+
volume_row = missing_volumes.setdefault(
|
|
5247
|
+
volume_root,
|
|
5248
|
+
{
|
|
5249
|
+
"volume_root_sanitized": _sanitize_media_path(volume_root, keep_filename=False),
|
|
5250
|
+
"mounted": os.path.isdir(volume_root),
|
|
5251
|
+
"clip_count": 0,
|
|
5252
|
+
"sample_basenames": [],
|
|
5253
|
+
},
|
|
5254
|
+
)
|
|
5255
|
+
volume_row["clip_count"] += 1
|
|
5256
|
+
if basename and basename not in volume_row["sample_basenames"] and len(volume_row["sample_basenames"]) < 8:
|
|
5257
|
+
volume_row["sample_basenames"].append(basename)
|
|
5258
|
+
|
|
5259
|
+
folder = os.path.dirname(str(file_path)) if file_path else ""
|
|
5260
|
+
if folder:
|
|
5261
|
+
folder_row = missing_folders.setdefault(
|
|
5262
|
+
folder,
|
|
5263
|
+
{
|
|
5264
|
+
"folder_sanitized": _sanitize_media_path(folder, keep_filename=False),
|
|
5265
|
+
"exists": os.path.isdir(folder),
|
|
5266
|
+
"clip_count": 0,
|
|
5267
|
+
"sample_basenames": [],
|
|
5268
|
+
},
|
|
5269
|
+
)
|
|
5270
|
+
folder_row["clip_count"] += 1
|
|
5271
|
+
if basename and basename not in folder_row["sample_basenames"] and len(folder_row["sample_basenames"]) < 8:
|
|
5272
|
+
folder_row["sample_basenames"].append(basename)
|
|
5273
|
+
|
|
5274
|
+
unique_items = list(by_media_pool_item.values())
|
|
5275
|
+
missing_volume_rows = list(missing_volumes.values())
|
|
5276
|
+
missing_folder_rows = list(missing_folders.values())
|
|
5277
|
+
absent_volumes = [row for row in missing_volume_rows if not row.get("mounted")]
|
|
5278
|
+
absent_folders = [row for row in missing_folder_rows if not row.get("exists")]
|
|
5279
|
+
primary_cause = "none"
|
|
5280
|
+
recommended_next_step = "No offline media detected."
|
|
5281
|
+
if absent_volumes:
|
|
5282
|
+
primary_cause = "volume_not_mounted"
|
|
5283
|
+
labels = ", ".join(row["volume_root_sanitized"] for row in absent_volumes[:3])
|
|
5284
|
+
recommended_next_step = f"Mount the missing volume(s): {labels}."
|
|
5285
|
+
elif absent_folders:
|
|
5286
|
+
primary_cause = "folder_not_found"
|
|
5287
|
+
labels = ", ".join(row["folder_sanitized"] for row in absent_folders[:3])
|
|
5288
|
+
recommended_next_step = f"Attach or locate the missing media folder(s): {labels}."
|
|
5289
|
+
elif unique_items:
|
|
5290
|
+
primary_cause = "files_missing_or_renamed"
|
|
5291
|
+
recommended_next_step = "Search approved media roots for the wanted basenames, then relink with media_pool.safe_relink."
|
|
5292
|
+
|
|
5293
|
+
return {
|
|
5294
|
+
"unique_media_pool_item_count": len(unique_items),
|
|
5295
|
+
"timeline_occurrence_count": len(missing_rows),
|
|
5296
|
+
"primary_cause": primary_cause,
|
|
5297
|
+
"recommended_next_step": recommended_next_step,
|
|
5298
|
+
"missing_volumes": missing_volume_rows,
|
|
5299
|
+
"missing_folders": missing_folder_rows,
|
|
5300
|
+
"unique_media_pool_items": unique_items,
|
|
5301
|
+
"sanitized": True,
|
|
5302
|
+
}
|
|
5303
|
+
|
|
5304
|
+
|
|
5188
5305
|
def _detect_missing_media_from_snapshot(snapshot: Dict[str, Any]):
|
|
5189
5306
|
missing = []
|
|
5190
5307
|
present = []
|
|
@@ -5210,12 +5327,80 @@ def _detect_missing_media_from_snapshot(snapshot: Dict[str, Any]):
|
|
|
5210
5327
|
missing.append(row)
|
|
5211
5328
|
else:
|
|
5212
5329
|
present.append(row)
|
|
5213
|
-
|
|
5330
|
+
diagnosis = _missing_media_diagnosis(missing)
|
|
5331
|
+
return {
|
|
5332
|
+
"missing": missing,
|
|
5333
|
+
"present_count": len(present),
|
|
5334
|
+
"missing_count": len(missing),
|
|
5335
|
+
"diagnosis": diagnosis,
|
|
5336
|
+
}
|
|
5214
5337
|
|
|
5215
5338
|
|
|
5216
5339
|
def _detect_missing_media(tl, p: Dict[str, Any]):
|
|
5217
5340
|
snapshot = _timeline_conform_snapshot(tl, {**p, "include_clip_properties": True})
|
|
5218
|
-
|
|
5341
|
+
report = _detect_missing_media_from_snapshot(snapshot)
|
|
5342
|
+
if p.get("sanitize_paths") or p.get("sanitized"):
|
|
5343
|
+
report = dict(report)
|
|
5344
|
+
report["missing"] = [
|
|
5345
|
+
{
|
|
5346
|
+
**row,
|
|
5347
|
+
"file_path_sanitized": _sanitize_media_path(row.get("file_path")),
|
|
5348
|
+
"file_path": None if p.get("omit_raw_paths", True) else row.get("file_path"),
|
|
5349
|
+
}
|
|
5350
|
+
for row in report.get("missing", [])
|
|
5351
|
+
]
|
|
5352
|
+
return report
|
|
5353
|
+
|
|
5354
|
+
|
|
5355
|
+
def _bounded_basename_matches(
|
|
5356
|
+
wanted: str,
|
|
5357
|
+
search_roots: List[str],
|
|
5358
|
+
p: Dict[str, Any],
|
|
5359
|
+
) -> Tuple[List[str], Dict[str, Any]]:
|
|
5360
|
+
started = time.monotonic()
|
|
5361
|
+
max_seconds = float(p.get("max_seconds", p.get("timeout_seconds", 20)) or 20)
|
|
5362
|
+
max_files = int(p.get("max_files_scanned", p.get("maxFilesScanned", 50000)) or 50000)
|
|
5363
|
+
max_depth_raw = p.get("max_depth", p.get("maxDepth"))
|
|
5364
|
+
max_depth = int(max_depth_raw) if max_depth_raw is not None else None
|
|
5365
|
+
all_matches = bool(p.get("all_matches", False))
|
|
5366
|
+
matches: List[str] = []
|
|
5367
|
+
files_scanned = 0
|
|
5368
|
+
dirs_scanned = 0
|
|
5369
|
+
stopped_reason = None
|
|
5370
|
+
|
|
5371
|
+
for root in search_roots:
|
|
5372
|
+
root_depth = len(os.path.abspath(root).rstrip(os.sep).split(os.sep))
|
|
5373
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
5374
|
+
dirs_scanned += 1
|
|
5375
|
+
if max_depth is not None:
|
|
5376
|
+
current_depth = len(os.path.abspath(dirpath).rstrip(os.sep).split(os.sep)) - root_depth
|
|
5377
|
+
if current_depth >= max_depth:
|
|
5378
|
+
dirnames[:] = []
|
|
5379
|
+
files_scanned += len(filenames)
|
|
5380
|
+
if wanted in filenames:
|
|
5381
|
+
matches.append(os.path.join(dirpath, wanted))
|
|
5382
|
+
if not all_matches:
|
|
5383
|
+
return matches, {
|
|
5384
|
+
"files_scanned": files_scanned,
|
|
5385
|
+
"dirs_scanned": dirs_scanned,
|
|
5386
|
+
"stopped_reason": "first_match",
|
|
5387
|
+
}
|
|
5388
|
+
if files_scanned >= max_files:
|
|
5389
|
+
stopped_reason = "max_files_scanned"
|
|
5390
|
+
break
|
|
5391
|
+
if time.monotonic() - started >= max_seconds:
|
|
5392
|
+
stopped_reason = "max_seconds"
|
|
5393
|
+
break
|
|
5394
|
+
if matches and not all_matches:
|
|
5395
|
+
break
|
|
5396
|
+
if stopped_reason:
|
|
5397
|
+
break
|
|
5398
|
+
|
|
5399
|
+
return matches, {
|
|
5400
|
+
"files_scanned": files_scanned,
|
|
5401
|
+
"dirs_scanned": dirs_scanned,
|
|
5402
|
+
"stopped_reason": stopped_reason,
|
|
5403
|
+
}
|
|
5219
5404
|
|
|
5220
5405
|
|
|
5221
5406
|
def _build_relink_plan(tl, p: Dict[str, Any]):
|
|
@@ -5226,26 +5411,55 @@ def _build_relink_plan(tl, p: Dict[str, Any]):
|
|
|
5226
5411
|
if invalid:
|
|
5227
5412
|
return _err(f"search_roots must be existing directories: {invalid}")
|
|
5228
5413
|
missing_report = _detect_missing_media(tl, p)
|
|
5414
|
+
diagnosis = missing_report.get("diagnosis") or _missing_media_diagnosis(missing_report.get("missing", []))
|
|
5415
|
+
if p.get("skip_search_when_volume_missing", True) and any(
|
|
5416
|
+
not row.get("mounted") for row in diagnosis.get("missing_volumes", [])
|
|
5417
|
+
):
|
|
5418
|
+
return {
|
|
5419
|
+
"success": True,
|
|
5420
|
+
"dry_run": True,
|
|
5421
|
+
"search_skipped": True,
|
|
5422
|
+
"skip_reason": "missing_source_volume_not_mounted",
|
|
5423
|
+
"search_roots": [_sanitize_media_path(root, keep_filename=False) for root in search_roots],
|
|
5424
|
+
"candidate_count": 0,
|
|
5425
|
+
"missing_count": missing_report.get("missing_count", 0),
|
|
5426
|
+
"diagnosis": diagnosis,
|
|
5427
|
+
"candidates": [],
|
|
5428
|
+
"execution_note": "Mount the missing source volume or pass skip_search_when_volume_missing=false to scan approved roots.",
|
|
5429
|
+
}
|
|
5229
5430
|
candidates = []
|
|
5431
|
+
rows_by_basename: Dict[str, List[Dict[str, Any]]] = {}
|
|
5230
5432
|
for row in missing_report.get("missing", []):
|
|
5231
5433
|
wanted = os.path.basename(str(row.get("file_path") or row.get("media_pool_item_name") or row.get("name") or ""))
|
|
5434
|
+
if wanted:
|
|
5435
|
+
rows_by_basename.setdefault(wanted, []).append(row)
|
|
5436
|
+
for wanted, rows in rows_by_basename.items():
|
|
5437
|
+
row = rows[0]
|
|
5232
5438
|
matches = []
|
|
5439
|
+
scan = {"files_scanned": 0, "dirs_scanned": 0, "stopped_reason": None}
|
|
5233
5440
|
if wanted:
|
|
5234
|
-
|
|
5235
|
-
|
|
5236
|
-
|
|
5237
|
-
|
|
5238
|
-
|
|
5239
|
-
|
|
5240
|
-
|
|
5241
|
-
|
|
5242
|
-
|
|
5441
|
+
matches, scan = _bounded_basename_matches(wanted, search_roots, p)
|
|
5442
|
+
sanitized_matches = [_sanitize_media_path(path) for path in matches]
|
|
5443
|
+
candidates.append(
|
|
5444
|
+
{
|
|
5445
|
+
**row,
|
|
5446
|
+
"wanted_basename": wanted,
|
|
5447
|
+
"timeline_occurrence_count": len(rows),
|
|
5448
|
+
"candidate_paths": [] if p.get("sanitize_paths") or p.get("sanitized") else matches,
|
|
5449
|
+
"candidate_paths_sanitized": sanitized_matches,
|
|
5450
|
+
"candidate_count": len(matches),
|
|
5451
|
+
"scan": scan,
|
|
5452
|
+
}
|
|
5453
|
+
)
|
|
5243
5454
|
return {
|
|
5244
5455
|
"success": True,
|
|
5245
5456
|
"dry_run": True,
|
|
5246
|
-
"search_roots": search_roots,
|
|
5457
|
+
"search_roots": [] if p.get("sanitize_paths") or p.get("sanitized") else search_roots,
|
|
5458
|
+
"search_roots_sanitized": [_sanitize_media_path(root, keep_filename=False) for root in search_roots],
|
|
5247
5459
|
"candidate_count": sum(1 for row in candidates if row["candidate_count"]),
|
|
5248
5460
|
"missing_count": missing_report.get("missing_count", 0),
|
|
5461
|
+
"unique_missing_basename_count": len(candidates),
|
|
5462
|
+
"diagnosis": diagnosis,
|
|
5249
5463
|
"candidates": candidates,
|
|
5250
5464
|
"execution_note": "Review this plan, then use media_pool.safe_relink with explicit synthetic or approved paths if desired.",
|
|
5251
5465
|
}
|
|
@@ -8373,6 +8587,13 @@ def _has_any_param(p: Dict[str, Any], *keys: str) -> bool:
|
|
|
8373
8587
|
return any(key in p and p[key] is not None for key in keys)
|
|
8374
8588
|
|
|
8375
8589
|
|
|
8590
|
+
def _media_analysis_vision_options(enabled: bool) -> Dict[str, Any]:
|
|
8591
|
+
options = {"enabled": bool(enabled)}
|
|
8592
|
+
if enabled:
|
|
8593
|
+
options["provider"] = HOST_CHAT_PATHS_PROVIDER
|
|
8594
|
+
return options
|
|
8595
|
+
|
|
8596
|
+
|
|
8376
8597
|
def _media_analysis_transcription_options(enabled: bool) -> Dict[str, Any]:
|
|
8377
8598
|
options = {"enabled": bool(enabled)}
|
|
8378
8599
|
if enabled:
|
|
@@ -8596,20 +8817,21 @@ def _media_analysis_apply_setup_defaults(action: str, p: Dict[str, Any]) -> Dict
|
|
|
8596
8817
|
|
|
8597
8818
|
if action in {"plan", "analyze_file", "analyze_clip", "analyze_bin", "analyze_project", "analyze_timeline", "analyze_sequence", "start_batch_job", "review_timeline_markers", "publish_clip_metadata"}:
|
|
8598
8819
|
include_visuals = _first_param(out, "include_visuals", "includeVisuals")
|
|
8599
|
-
if
|
|
8820
|
+
if isinstance(out.get("vision"), bool):
|
|
8821
|
+
out["vision"] = _media_analysis_vision_options(out["vision"])
|
|
8822
|
+
applied["vision_shorthand"] = out["vision"]["enabled"]
|
|
8823
|
+
elif not _has_any_param(out, "vision"):
|
|
8600
8824
|
if include_visuals is not None:
|
|
8601
8825
|
visuals_enabled = _media_analysis_bool(include_visuals, True)
|
|
8602
|
-
out["vision"] =
|
|
8603
|
-
if visuals_enabled:
|
|
8604
|
-
out["vision"]["provider"] = HOST_CHAT_PATHS_PROVIDER
|
|
8826
|
+
out["vision"] = _media_analysis_vision_options(visuals_enabled)
|
|
8605
8827
|
applied["include_visuals"] = visuals_enabled
|
|
8606
8828
|
else:
|
|
8607
8829
|
vision_default = prefs.get("vision_default")
|
|
8608
8830
|
if vision_default == "on":
|
|
8609
|
-
out["vision"] =
|
|
8831
|
+
out["vision"] = _media_analysis_vision_options(True)
|
|
8610
8832
|
applied["vision_default"] = vision_default
|
|
8611
8833
|
elif vision_default in {"off", "technical_only"}:
|
|
8612
|
-
out["vision"] =
|
|
8834
|
+
out["vision"] = _media_analysis_vision_options(False)
|
|
8613
8835
|
applied["vision_default"] = vision_default
|
|
8614
8836
|
include_transcription = _first_param(out, "include_transcription", "includeTranscription")
|
|
8615
8837
|
if not _has_any_param(out, "transcription"):
|
|
@@ -12862,9 +13084,47 @@ def _render_formats(proj):
|
|
|
12862
13084
|
return _ser(formats)
|
|
12863
13085
|
|
|
12864
13086
|
|
|
12865
|
-
def
|
|
13087
|
+
def _render_format_id_from_formats(formats: Any, fmt: str) -> str:
|
|
13088
|
+
if not isinstance(fmt, str) or not fmt or not isinstance(formats, dict):
|
|
13089
|
+
return fmt
|
|
13090
|
+
if fmt in formats:
|
|
13091
|
+
return formats.get(fmt) or fmt
|
|
13092
|
+
if fmt in formats.values():
|
|
13093
|
+
return fmt
|
|
13094
|
+
needle = fmt.lower()
|
|
13095
|
+
for label, format_id in formats.items():
|
|
13096
|
+
label_text = str(label)
|
|
13097
|
+
id_text = str(format_id)
|
|
13098
|
+
if label_text.lower() == needle:
|
|
13099
|
+
return id_text or fmt
|
|
13100
|
+
if id_text.lower() == needle:
|
|
13101
|
+
return id_text
|
|
13102
|
+
return fmt
|
|
13103
|
+
|
|
13104
|
+
|
|
13105
|
+
def _render_format_id(proj, fmt: str, formats: Optional[Dict[str, Any]] = None) -> str:
|
|
13106
|
+
if formats is None:
|
|
13107
|
+
formats = _render_formats(proj)
|
|
13108
|
+
return _render_format_id_from_formats(formats, fmt)
|
|
13109
|
+
|
|
13110
|
+
|
|
13111
|
+
def _render_format_requested(requested: Optional[List[Any]], label: str, extension: Any, format_id: str, formats: Dict[str, Any]) -> bool:
|
|
13112
|
+
if not requested:
|
|
13113
|
+
return True
|
|
13114
|
+
extension_text = str(extension)
|
|
13115
|
+
for item in requested:
|
|
13116
|
+
if item == label or item == extension or item == format_id:
|
|
13117
|
+
return True
|
|
13118
|
+
if isinstance(item, str) and _render_format_id_from_formats(formats, item) == format_id:
|
|
13119
|
+
return True
|
|
13120
|
+
if isinstance(item, str) and item.lower() in {label.lower(), extension_text.lower(), format_id.lower()}:
|
|
13121
|
+
return True
|
|
13122
|
+
return False
|
|
13123
|
+
|
|
13124
|
+
|
|
13125
|
+
def _render_codecs(proj, fmt: str, formats: Optional[Dict[str, Any]] = None):
|
|
12866
13126
|
try:
|
|
12867
|
-
return _ser(proj.GetRenderCodecs(fmt) or {})
|
|
13127
|
+
return _ser(proj.GetRenderCodecs(_render_format_id(proj, fmt, formats)) or {})
|
|
12868
13128
|
except Exception as exc:
|
|
12869
13129
|
return {"error": str(exc)}
|
|
12870
13130
|
|
|
@@ -12909,10 +13169,11 @@ def _probe_render_matrix(proj, p: Dict[str, Any]):
|
|
|
12909
13169
|
pair_count = 0
|
|
12910
13170
|
errors = []
|
|
12911
13171
|
for fmt, extension in formats.items():
|
|
12912
|
-
|
|
13172
|
+
format_id = _render_format_id_from_formats(formats, fmt)
|
|
13173
|
+
if not _render_format_requested(requested, fmt, extension, format_id, formats):
|
|
12913
13174
|
continue
|
|
12914
|
-
codecs = _render_codecs(proj,
|
|
12915
|
-
format_row = {"format": fmt, "extension": extension, "codecs": [], "codec_count": 0}
|
|
13175
|
+
codecs = _render_codecs(proj, format_id, formats)
|
|
13176
|
+
format_row = {"format": fmt, "extension": extension, "format_id": format_id, "codecs": [], "codec_count": 0}
|
|
12916
13177
|
if isinstance(codecs, dict) and codecs.get("error"):
|
|
12917
13178
|
format_row["error"] = codecs["error"]
|
|
12918
13179
|
errors.append({"format": fmt, "error": codecs["error"]})
|
|
@@ -12925,11 +13186,11 @@ def _probe_render_matrix(proj, p: Dict[str, Any]):
|
|
|
12925
13186
|
break
|
|
12926
13187
|
row = {"label": label, "codec": codec}
|
|
12927
13188
|
try:
|
|
12928
|
-
row["resolutions"] = _ser(proj.GetRenderResolutions(
|
|
13189
|
+
row["resolutions"] = _ser(proj.GetRenderResolutions(format_id, codec) or [])
|
|
12929
13190
|
row["resolution_count"] = len(row["resolutions"])
|
|
12930
13191
|
except Exception as exc:
|
|
12931
13192
|
row["error"] = str(exc)
|
|
12932
|
-
errors.append({"format": fmt, "codec": codec, "error": str(exc)})
|
|
13193
|
+
errors.append({"format": fmt, "format_id": format_id, "codec": codec, "error": str(exc)})
|
|
12933
13194
|
format_row["codecs"].append(row)
|
|
12934
13195
|
pair_count += 1
|
|
12935
13196
|
matrix.append(format_row)
|
|
@@ -13057,7 +13318,7 @@ def _prepare_render_job(proj, p: Dict[str, Any]):
|
|
|
13057
13318
|
before = _render_settings_snapshot(proj)
|
|
13058
13319
|
format_success = None
|
|
13059
13320
|
if p.get("format") and p.get("codec"):
|
|
13060
|
-
format_success = bool(proj.SetCurrentRenderFormatAndCodec(p["format"], p["codec"]))
|
|
13321
|
+
format_success = bool(proj.SetCurrentRenderFormatAndCodec(_render_format_id(proj, p["format"]), p["codec"]))
|
|
13061
13322
|
settings_success = bool(proj.SetRenderSettings(settings))
|
|
13062
13323
|
job_id = proj.AddRenderJob() if settings_success else None
|
|
13063
13324
|
return {
|
|
@@ -13208,17 +13469,17 @@ def render(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, An
|
|
|
13208
13469
|
elif action == "get_formats":
|
|
13209
13470
|
return {"formats": _ser(proj.GetRenderFormats())}
|
|
13210
13471
|
elif action == "get_codecs":
|
|
13211
|
-
return {"codecs":
|
|
13472
|
+
return {"codecs": _render_codecs(proj, p["format"])}
|
|
13212
13473
|
elif action == "get_format_and_codec":
|
|
13213
13474
|
return _ser(proj.GetCurrentRenderFormatAndCodec())
|
|
13214
13475
|
elif action == "set_format_and_codec":
|
|
13215
|
-
return {"success": bool(proj.SetCurrentRenderFormatAndCodec(p["format"], p["codec"]))}
|
|
13476
|
+
return {"success": bool(proj.SetCurrentRenderFormatAndCodec(_render_format_id(proj, p["format"]), p["codec"]))}
|
|
13216
13477
|
elif action == "get_mode":
|
|
13217
13478
|
return {"mode": proj.GetCurrentRenderMode()}
|
|
13218
13479
|
elif action == "set_mode":
|
|
13219
13480
|
return {"success": bool(proj.SetCurrentRenderMode(p["mode"]))}
|
|
13220
13481
|
elif action == "get_resolutions":
|
|
13221
|
-
return {"resolutions": _ser(proj.GetRenderResolutions(p["format"], p["codec"]))}
|
|
13482
|
+
return {"resolutions": _ser(proj.GetRenderResolutions(_render_format_id(proj, p["format"]), p["codec"]))}
|
|
13222
13483
|
elif action == "get_settings":
|
|
13223
13484
|
missing = _requires_method(proj, "GetRenderSettings", "unknown")
|
|
13224
13485
|
if missing:
|
|
@@ -15298,8 +15559,8 @@ def timeline(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str,
|
|
|
15298
15559
|
import_timeline_checked(path, options?, timeline_name?, import_source_clips?, require_temp_path?, dry_run?) -> {success, name, id}
|
|
15299
15560
|
compare_timelines(right_timeline_id?|right_timeline_index?|left_snapshot?, right_snapshot?) -> {match, differences}
|
|
15300
15561
|
probe_interchange_roundtrip(format?, output_dir?, cleanup_imported?) -> {success, export, import, comparison}
|
|
15301
|
-
detect_missing_media() -> {missing, missing_count}
|
|
15302
|
-
build_relink_plan(search_roots) -> {candidates}
|
|
15562
|
+
detect_missing_media(sanitized?|sanitize_paths?, omit_raw_paths?) -> {missing, missing_count, diagnosis}
|
|
15563
|
+
build_relink_plan(search_roots, max_depth?, max_seconds?, max_files_scanned?, skip_search_when_volume_missing?, sanitized?) -> {candidates, diagnosis}
|
|
15303
15564
|
conform_boundary_report(...) -> {capabilities, timeline, gaps_overlaps, source_ranges, missing_media}
|
|
15304
15565
|
audio_capabilities() -> {supported, partially_supported, unsupported}
|
|
15305
15566
|
probe_audio_item(track_type?, track_index?, item_index?) -> {summary, audio_properties, source_audio_mapping}
|
|
@@ -15914,7 +16175,14 @@ def timeline_markers(action: str, params: Optional[Dict[str, Any]] = None) -> An
|
|
|
15914
16175
|
it = tl.GetCurrentVideoItem()
|
|
15915
16176
|
return {"name": it.GetName(), "id": it.GetUniqueId()} if it else {"name": None, "id": None}
|
|
15916
16177
|
elif action == "get_thumbnail":
|
|
15917
|
-
|
|
16178
|
+
thumbnail = tl.GetCurrentClipThumbnailImage()
|
|
16179
|
+
if thumbnail is None:
|
|
16180
|
+
return {
|
|
16181
|
+
"success": False,
|
|
16182
|
+
"thumbnail": None,
|
|
16183
|
+
"error": "Resolve did not return a thumbnail for the current playhead. Open the Color page and ensure a video item is under the playhead.",
|
|
16184
|
+
}
|
|
16185
|
+
return _ser(thumbnail)
|
|
15918
16186
|
elif action == "get_thumbnail_image":
|
|
15919
16187
|
thumbnail = tl.GetCurrentClipThumbnailImage()
|
|
15920
16188
|
if not thumbnail:
|