davinci-resolve-mcp 2.36.1 → 2.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,40 @@
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.0
6
+
7
+ Render format-id fix, offline-media diagnosis, and a setup doctor.
8
+
9
+ - **Fixed** render helpers passing the display name from `GetRenderFormats()`
10
+ (e.g. `QuickTime`) into `GetRenderCodecs`, `GetRenderResolutions`, and
11
+ `SetCurrentRenderFormatAndCodec`, which expect the format id (e.g. `mov`).
12
+ `GetRenderCodecs("QuickTime")` returned empty, so `probe_render_matrix`
13
+ reported no QuickTime codecs and `prepare_render_job` could not target
14
+ ProRes (e.g. `ProRes422LT` proxy renders). Display names and format ids are
15
+ now both accepted as input and normalized to the id for Resolve API calls;
16
+ `probe_render_matrix` rows include `format_id`. Closes #59.
17
+ - **Fixed** the destructive confirm-token gate referencing a stale function
18
+ name, which made it silently ignore the `destructive.require_confirm_token`
19
+ preference and always fall back to the default.
20
+ - **Added** a `diagnosis` block to `timeline.detect_missing_media`:
21
+ deduplicated missing Media Pool items, volume/folder mounted checks, a
22
+ primary cause (`volume_not_mounted` / `folder_not_found` /
23
+ `files_missing_or_renamed`), and a recommended next step. Optional
24
+ `sanitized`/`sanitize_paths` redacts raw media paths.
25
+ - **Added** bounds to `timeline.build_relink_plan`: skips the broad scan by
26
+ default when a source volume (e.g. a camera card) is not mounted, dedupes
27
+ missing basenames, and caps the search with `max_depth`, `max_seconds`
28
+ (default 20s), and `max_files_scanned` (default 50k), reporting per-candidate
29
+ scan stats.
30
+ - **Fixed** `media_analysis` accepting `vision: true/false` boolean shorthand
31
+ (now normalized to the full options dict) and `timeline_markers.get_thumbnail`
32
+ returning a structured error instead of raw `None` when no thumbnail is
33
+ available.
34
+ - **Added** `scripts/doctor.py`, a read-only setup diagnostic that checks the
35
+ checkout, Python, Resolve app/scripting paths, and MCP client configs, and
36
+ probes the scripting bridge end to end with OK/WARN/FAIL output (`--json`
37
+ supported).
38
+
5
39
  ## What's New in v2.36.1
6
40
 
7
41
  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
- [![Version](https://img.shields.io/badge/version-2.36.1-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
3
+ [![Version](https://img.shields.io/badge/version-2.37.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
4
4
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
5
5
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
6
- [![Tools](https://img.shields.io/badge/MCP%20Tools-32%20(341%20full)-blue.svg)](#server-modes)
6
+ [![Tools](https://img.shields.io/badge/MCP%20Tools-33%20(341%20full)-blue.svg)](#server-modes)
7
7
  [![Tested](https://img.shields.io/badge/Live%20Tested-98.5%25-green.svg)](docs/reference/api-coverage.md#test-results)
8
8
  [![DaVinci Resolve](https://img.shields.io/badge/DaVinci%20Resolve-18.5+-darkred.svg)](https://www.blackmagicdesign.com/products/davinciresolve)
9
9
  [![Python](https://img.shields.io/badge/python-3.10+-green.svg)](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` | 32 | Default mode for most assistants. Related Resolve operations are grouped behind action parameters to keep context usage low. |
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. Execute relinks through
78
- `media_pool.safe_relink` only with synthetic or explicitly approved paths.
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.36.1"
38
+ VERSION = "2.37.0"
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "davinci-resolve-mcp",
3
- "version": "2.36.1",
3
+ "version": "2.37.0",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -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())
@@ -80,7 +80,7 @@ if not logging.getLogger().handlers:
80
80
  handlers=[logging.StreamHandler()],
81
81
  )
82
82
 
83
- VERSION = "2.36.1"
83
+ VERSION = "2.37.0"
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.36.1"
14
+ VERSION = "2.37.0"
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 = _media_analysis_preferences_read() if "_media_analysis_preferences_read" in globals() else {}
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
- return {"missing": missing, "present_count": len(present), "missing_count": len(missing)}
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
- return _detect_missing_media_from_snapshot(snapshot)
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
- for root in search_roots:
5235
- for dirpath, _, filenames in os.walk(root):
5236
- if wanted in filenames:
5237
- matches.append(os.path.join(dirpath, wanted))
5238
- if not p.get("all_matches", False):
5239
- break
5240
- if matches and not p.get("all_matches", False):
5241
- break
5242
- candidates.append({**row, "wanted_basename": wanted, "candidate_paths": matches, "candidate_count": len(matches)})
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 not _has_any_param(out, "vision"):
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"] = {"enabled": visuals_enabled}
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"] = {"enabled": True, "provider": HOST_CHAT_PATHS_PROVIDER}
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"] = {"enabled": False}
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 _render_codecs(proj, fmt: str):
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
- if requested and fmt not in requested:
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, fmt)
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(fmt, codec) or [])
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": _ser(proj.GetRenderCodecs(p["format"]))}
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
- return _ser(tl.GetCurrentClipThumbnailImage())
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: