davinci-resolve-mcp 2.27.0 → 2.27.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 +34 -0
- package/README.md +1 -1
- package/install.py +1 -1
- package/package.json +1 -1
- package/src/analysis_dashboard.py +425 -105
- package/src/granular/common.py +1 -1
- package/src/server.py +1 -1
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.27.1
|
|
6
|
+
|
|
7
|
+
**Faster control-panel startup with network source media (issue #50)** — on
|
|
8
|
+
first open the control panel could sit on "connection pending" for a long time
|
|
9
|
+
when Media Pool clips lived on mounted network storage, because the UI only
|
|
10
|
+
treated the connection as live once the full media inventory finished loading,
|
|
11
|
+
and that inventory probed every clip's file path on disk.
|
|
12
|
+
|
|
13
|
+
Fixes and performance work:
|
|
14
|
+
|
|
15
|
+
- **Connection state is decoupled from the media inventory.** The overview and
|
|
16
|
+
diagnostics panels now derive "connected" from the `/api/boot` handshake (which
|
|
17
|
+
returns as soon as the Resolve bridge is reachable) and show inventory loading
|
|
18
|
+
separately, so Resolve reads as live immediately while clips stream in.
|
|
19
|
+
- **Parallel, cached file-existence probing.** `os.path.exists` for every clip
|
|
20
|
+
now runs in a thread pool and is memoized for a short TTL, instead of two serial
|
|
21
|
+
`stat()` calls per clip — the dominant cost on network storage.
|
|
22
|
+
- **Background polls reuse the cached Media Pool walk.** The recurring poll no
|
|
23
|
+
longer re-runs the ~N serial `GetClipProperty` calls; it reuses the last walk
|
|
24
|
+
and re-applies only the local, disk-backed analysis-status overlay. A cheap
|
|
25
|
+
project-id check still catches a project switched directly in Resolve, and a
|
|
26
|
+
manual refresh always does a full walk.
|
|
27
|
+
- **Resolve scripting API access is serialized.** A re-entrant lock guards every
|
|
28
|
+
scripting-API entry point, since the dashboard's threaded HTTP server could
|
|
29
|
+
previously fire concurrent (thread-unsafe) Resolve calls at startup.
|
|
30
|
+
- **ETag/304 on the inventory endpoint** skips transfer and table re-render when
|
|
31
|
+
nothing changed; the last good inventory is cached client-side and painted
|
|
32
|
+
instantly on reload; and the first inventory build is warmed in a background
|
|
33
|
+
thread at server start.
|
|
34
|
+
|
|
35
|
+
No public MCP tool surface changed. Adds regression tests in
|
|
36
|
+
`tests/test_media_analysis.py` (path-existence probing, inventory cache reuse,
|
|
37
|
+
project-switch detection, lock reentrancy).
|
|
38
|
+
|
|
5
39
|
## What's New in v2.27.0
|
|
6
40
|
|
|
7
41
|
**Frame-sampling modes (issue #46)** — how many frames a clip gets for visual
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
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
6
|
[-blue.svg)](#server-modes)
|
package/install.py
CHANGED
|
@@ -35,7 +35,7 @@ from src.utils.update_check import (
|
|
|
35
35
|
|
|
36
36
|
# ─── Version ──────────────────────────────────────────────────────────────────
|
|
37
37
|
|
|
38
|
-
VERSION = "2.27.
|
|
38
|
+
VERSION = "2.27.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
|
@@ -4,13 +4,17 @@
|
|
|
4
4
|
from __future__ import annotations
|
|
5
5
|
|
|
6
6
|
import argparse
|
|
7
|
+
import functools
|
|
8
|
+
import hashlib
|
|
7
9
|
import json
|
|
8
10
|
import os
|
|
9
11
|
import re
|
|
10
12
|
import sqlite3
|
|
11
13
|
import sys
|
|
12
14
|
import threading
|
|
15
|
+
import time
|
|
13
16
|
import webbrowser
|
|
17
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
14
18
|
from http import HTTPStatus
|
|
15
19
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
16
20
|
from typing import Any, Dict, List, Optional, Tuple
|
|
@@ -4636,6 +4640,8 @@ HTML = r"""<!doctype html>
|
|
|
4636
4640
|
allProjects: null,
|
|
4637
4641
|
activeContext: null,
|
|
4638
4642
|
resolveMedia: null,
|
|
4643
|
+
resolveMediaStale: false,
|
|
4644
|
+
mediaETag: null,
|
|
4639
4645
|
mediaPollTimer: null,
|
|
4640
4646
|
mediaRefreshing: false,
|
|
4641
4647
|
mediaLastRefresh: null,
|
|
@@ -5032,18 +5038,34 @@ HTML = r"""<!doctype html>
|
|
|
5032
5038
|
const clips = sourceClips();
|
|
5033
5039
|
const hiddenRecords = Math.max(0, Number(counts.total || 0) - Number(counts.source_clips || clips.length));
|
|
5034
5040
|
const sequences = sequenceCount(media);
|
|
5041
|
+
// Connection state comes from the /api/boot handshake, which returns as soon
|
|
5042
|
+
// as the Resolve bridge is reachable — independent of the media inventory,
|
|
5043
|
+
// which can take a long time to probe network source media. inventoryPending
|
|
5044
|
+
// means Resolve is live but /api/resolve/media hasn't returned yet.
|
|
5045
|
+
const bootResolve = state.boot?.resolve || {};
|
|
5046
|
+
const resolveConnected = bootResolve.available === true || !!media?.resolve_available;
|
|
5047
|
+
const inventoryPending = resolveConnected && !media;
|
|
5035
5048
|
const projectName = state.activeContext?.project_name || media?.project?.name || state.boot?.project_name || 'Resolve project';
|
|
5036
|
-
const resolveProject = media?.project?.name || 'No Resolve project';
|
|
5037
|
-
|
|
5049
|
+
const resolveProject = media?.project?.name || (resolveConnected ? 'Loading project…' : 'No Resolve project');
|
|
5050
|
+
let resolveStatus;
|
|
5051
|
+
if (media?.resolve_available) {
|
|
5052
|
+
resolveStatus = `Resolve: ${resolveProject} · read-only`;
|
|
5053
|
+
} else if (inventoryPending) {
|
|
5054
|
+
resolveStatus = 'Resolve connected · loading inventory…';
|
|
5055
|
+
} else if (resolveConnected) {
|
|
5056
|
+
resolveStatus = media?.status || media?.error || 'Resolve connected';
|
|
5057
|
+
} else {
|
|
5058
|
+
resolveStatus = media?.status || bootResolve.error || 'Connection pending';
|
|
5059
|
+
}
|
|
5038
5060
|
const index = indexSummary();
|
|
5039
5061
|
const readyClips = clips.filter(clip => clip.analyzable).length;
|
|
5040
5062
|
const analyzedClips = clips.filter(clip => ['analyzed', 'succeeded', 'skipped'].includes(String(clip.analysis_status || ''))).length;
|
|
5041
5063
|
const onlineClips = clips.filter(clip => String(clip.status || '') === 'online').length;
|
|
5042
5064
|
const missingClips = clips.filter(clip => ['missing_file', 'offline'].includes(String(clip.status || ''))).length;
|
|
5043
|
-
const mediaStatusLabel = media?.resolve_available ? `${onlineClips} online` : 'Unavailable';
|
|
5065
|
+
const mediaStatusLabel = media?.resolve_available ? `${onlineClips} online` : (inventoryPending ? 'Loading…' : 'Unavailable');
|
|
5044
5066
|
const mediaStatusDetail = media?.resolve_available
|
|
5045
5067
|
? `${missingClips} missing/offline · ${hiddenRecords} non-source records`
|
|
5046
|
-
: (media?.error || media?.status || 'Resolve inventory pending');
|
|
5068
|
+
: (inventoryPending ? 'Reading Media Pool inventory…' : (media?.error || media?.status || 'Resolve inventory pending'));
|
|
5047
5069
|
|
|
5048
5070
|
setText('overviewUpdated', `Updated ${new Date().toLocaleTimeString()}`);
|
|
5049
5071
|
setText('overviewProject', projectName);
|
|
@@ -5056,7 +5078,10 @@ HTML = r"""<!doctype html>
|
|
|
5056
5078
|
setText('overviewMediaStatusDetail', mediaStatusDetail);
|
|
5057
5079
|
|
|
5058
5080
|
if (!media?.resolve_available) {
|
|
5059
|
-
|
|
5081
|
+
const emptyMsg = inventoryPending
|
|
5082
|
+
? 'Resolve connected — loading Media Pool inventory…'
|
|
5083
|
+
: (media?.error || 'Open Resolve with a project loaded to inspect clips.');
|
|
5084
|
+
setHtml('overviewStatusList', `<div class="empty">${escapeHtml(emptyMsg)}</div>`);
|
|
5060
5085
|
} else {
|
|
5061
5086
|
const ICONS = {
|
|
5062
5087
|
project: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7h5l2 3h11v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>',
|
|
@@ -5109,9 +5134,13 @@ HTML = r"""<!doctype html>
|
|
|
5109
5134
|
const index = indexSummary();
|
|
5110
5135
|
const resolveInfo = state.boot?.resolve || {};
|
|
5111
5136
|
const warnings = media?.warnings || [];
|
|
5112
|
-
|
|
5113
|
-
const
|
|
5114
|
-
const
|
|
5137
|
+
// The boot handshake establishes the connection; media inventory loads after.
|
|
5138
|
+
const handshake = resolveInfo.available === true;
|
|
5139
|
+
const resolveOnline = !!media?.resolve_available || handshake;
|
|
5140
|
+
const inventoryPending = handshake && !media;
|
|
5141
|
+
const offline = (media || resolveInfo.error) && !resolveOnline;
|
|
5142
|
+
const resolveTone = resolveOnline ? 'pill-ok' : (offline ? 'pill-err' : 'pill-mute');
|
|
5143
|
+
const resolveLabel = resolveOnline ? 'Connected' : (offline ? 'Offline' : 'Pending');
|
|
5115
5144
|
|
|
5116
5145
|
const connectionCard = `
|
|
5117
5146
|
<div class="diag-card">
|
|
@@ -5123,7 +5152,7 @@ HTML = r"""<!doctype html>
|
|
|
5123
5152
|
${diagRow('Product', resolveInfo.product || (resolveOnline ? 'DaVinci Resolve' : 'Unavailable'), { muted: !resolveOnline })}
|
|
5124
5153
|
${diagRow('Version', resolveInfo.version_string || '—', { muted: !resolveInfo.version_string })}
|
|
5125
5154
|
${diagRow('Active page', resolveInfo.page || '—', { muted: !resolveInfo.page })}
|
|
5126
|
-
${diagRow('Status', resolveOnline ? 'Read-only API live' : (media?.error || media?.status || 'Waiting for handshake'))}
|
|
5155
|
+
${diagRow('Status', inventoryPending ? 'Read-only API live · loading inventory…' : (resolveOnline ? 'Read-only API live' : (media?.error || resolveInfo.error || media?.status || 'Waiting for handshake')))}
|
|
5127
5156
|
</div>
|
|
5128
5157
|
</div>`;
|
|
5129
5158
|
|
|
@@ -5926,6 +5955,14 @@ HTML = r"""<!doctype html>
|
|
|
5926
5955
|
syncPreferencesPanel();
|
|
5927
5956
|
applyPreferencesToControls(prefs);
|
|
5928
5957
|
renderVersionBadge();
|
|
5958
|
+
// Paint the previous inventory immediately (stale) so a slow first
|
|
5959
|
+
// /api/resolve/media — common with network source media — doesn't leave the
|
|
5960
|
+
// panel blank. The live fetch below replaces it and clears the stale flag.
|
|
5961
|
+
const cachedInventory = loadInventorySnapshot();
|
|
5962
|
+
if (cachedInventory) {
|
|
5963
|
+
state.resolveMediaStale = true;
|
|
5964
|
+
renderResolveMedia(cachedInventory);
|
|
5965
|
+
}
|
|
5929
5966
|
await refreshProjectContexts();
|
|
5930
5967
|
renderControlPanels();
|
|
5931
5968
|
await refreshIndex();
|
|
@@ -6057,15 +6094,56 @@ HTML = r"""<!doctype html>
|
|
|
6057
6094
|
state.mediaRefreshing = true;
|
|
6058
6095
|
updateMediaPollStatus('refreshing');
|
|
6059
6096
|
try {
|
|
6060
|
-
|
|
6097
|
+
// Background polls reuse the cached Resolve walk and skip the network FS
|
|
6098
|
+
// probe (the server re-applies only the local analysis overlay); manual /
|
|
6099
|
+
// first loads do a full Media Pool walk with a fresh probe. The ETag lets
|
|
6100
|
+
// an unchanged poll short-circuit to 304 and skip the table re-render.
|
|
6101
|
+
const query = options.silent ? '&probe=0&reuse=1' : '';
|
|
6102
|
+
const headers = {};
|
|
6103
|
+
if (state.mediaETag) headers['If-None-Match'] = state.mediaETag;
|
|
6104
|
+
const res = await fetch(`/api/resolve/media?limit=500${query}`, { headers, cache: 'no-store' });
|
|
6061
6105
|
state.mediaLastRefresh = new Date();
|
|
6106
|
+
state.resolveMediaStale = false;
|
|
6107
|
+
if (res.status === 304) return;
|
|
6108
|
+
const payload = await res.json();
|
|
6109
|
+
if (!res.ok || payload.success === false) {
|
|
6110
|
+
throw new Error(payload.error || res.statusText);
|
|
6111
|
+
}
|
|
6112
|
+
state.mediaETag = res.headers.get('ETag') || state.mediaETag;
|
|
6062
6113
|
renderResolveMedia(payload);
|
|
6114
|
+
saveInventorySnapshot(payload);
|
|
6063
6115
|
} finally {
|
|
6064
6116
|
state.mediaRefreshing = false;
|
|
6065
6117
|
updateMediaPollStatus();
|
|
6066
6118
|
}
|
|
6067
6119
|
}
|
|
6068
6120
|
|
|
6121
|
+
// Persist the last good inventory so reopening the dashboard paints the
|
|
6122
|
+
// previous snapshot instantly (with a "refreshing" hint) instead of sitting
|
|
6123
|
+
// on "connection pending" until the first fetch returns.
|
|
6124
|
+
function inventorySnapshotKey() {
|
|
6125
|
+
return 'resolveMcpInventory:' + (state.activeContext?.project_root || state.boot?.project_root || state.boot?.project_name || 'default');
|
|
6126
|
+
}
|
|
6127
|
+
function saveInventorySnapshot(payload) {
|
|
6128
|
+
if (!payload?.resolve_available) return;
|
|
6129
|
+
try {
|
|
6130
|
+
localStorage.setItem(inventorySnapshotKey(), JSON.stringify({ saved_at: Date.now(), payload }));
|
|
6131
|
+
} catch (error) {
|
|
6132
|
+
// Quota or serialization failure is non-fatal — the snapshot is a nicety.
|
|
6133
|
+
console.warn('Could not cache inventory snapshot', error);
|
|
6134
|
+
}
|
|
6135
|
+
}
|
|
6136
|
+
function loadInventorySnapshot() {
|
|
6137
|
+
try {
|
|
6138
|
+
const raw = localStorage.getItem(inventorySnapshotKey());
|
|
6139
|
+
if (!raw) return null;
|
|
6140
|
+
const parsed = JSON.parse(raw);
|
|
6141
|
+
return parsed?.payload?.resolve_available ? parsed.payload : null;
|
|
6142
|
+
} catch (error) {
|
|
6143
|
+
return null;
|
|
6144
|
+
}
|
|
6145
|
+
}
|
|
6146
|
+
|
|
6069
6147
|
function promptCandidateClips() {
|
|
6070
6148
|
return filteredResolveClips(state.resolveMedia?.clips || [])
|
|
6071
6149
|
.filter(clip => clip.source_clip && clip.analyzable && clip.clip_id);
|
|
@@ -6550,7 +6628,8 @@ HTML = r"""<!doctype html>
|
|
|
6550
6628
|
const poll = enabled ? `polling every ${Math.round(interval / 1000)}s` : 'polling off';
|
|
6551
6629
|
const visible = state.resolveMedia?.clips ? clipLabel(filteredResolveClips(state.resolveMedia.clips).length) : 'no media snapshot';
|
|
6552
6630
|
const prefix = state.mediaRefreshing ? 'refreshing' : poll;
|
|
6553
|
-
|
|
6631
|
+
const stale = state.resolveMediaStale ? 'cached · ' : '';
|
|
6632
|
+
el.textContent = `${stale}${prefix} · last ${last} · ${visible}${extra ? ` · ${extra}` : ''}`;
|
|
6554
6633
|
}
|
|
6555
6634
|
|
|
6556
6635
|
function rerenderResolveMedia() {
|
|
@@ -10504,27 +10583,74 @@ def _safe_id(obj: Any) -> Optional[str]:
|
|
|
10504
10583
|
return str(value) if value else None
|
|
10505
10584
|
|
|
10506
10585
|
|
|
10586
|
+
# ── Resolve scripting API serialization ─────────────────────────────────────
|
|
10587
|
+
# The dashboard runs on a ThreadingHTTPServer, so /api/boot, /api/projects and
|
|
10588
|
+
# /api/resolve/media can land on separate threads concurrently (especially at
|
|
10589
|
+
# startup). DaVinci's scripting API is not thread-safe, so every entry point that
|
|
10590
|
+
# talks to it acquires this re-entrant lock for the full duration of its calls.
|
|
10591
|
+
_RESOLVE_API_LOCK = threading.RLock()
|
|
10592
|
+
_RESOLVE_ENV_READY = False
|
|
10593
|
+
|
|
10594
|
+
|
|
10595
|
+
def _serialize_resolve(func):
|
|
10596
|
+
"""Decorator: hold the Resolve API lock for the whole call."""
|
|
10597
|
+
@functools.wraps(func)
|
|
10598
|
+
def wrapper(*args, **kwargs):
|
|
10599
|
+
with _RESOLVE_API_LOCK:
|
|
10600
|
+
return func(*args, **kwargs)
|
|
10601
|
+
return wrapper
|
|
10602
|
+
|
|
10603
|
+
|
|
10507
10604
|
def _connect_resolve_read_only() -> Tuple[Any, Optional[str]]:
|
|
10508
|
-
|
|
10509
|
-
|
|
10510
|
-
|
|
10511
|
-
|
|
10512
|
-
if
|
|
10513
|
-
|
|
10514
|
-
|
|
10515
|
-
|
|
10516
|
-
|
|
10517
|
-
|
|
10518
|
-
|
|
10519
|
-
|
|
10520
|
-
|
|
10521
|
-
|
|
10522
|
-
|
|
10523
|
-
|
|
10524
|
-
|
|
10525
|
-
|
|
10605
|
+
global _RESOLVE_ENV_READY
|
|
10606
|
+
with _RESOLVE_API_LOCK:
|
|
10607
|
+
# Environment + sys.path setup is pure overhead and never goes stale, so
|
|
10608
|
+
# run it once per process rather than on every connection.
|
|
10609
|
+
if not _RESOLVE_ENV_READY:
|
|
10610
|
+
try:
|
|
10611
|
+
setup_environment()
|
|
10612
|
+
modules_path = os.environ.get("RESOLVE_SCRIPT_API")
|
|
10613
|
+
if modules_path:
|
|
10614
|
+
candidate = os.path.join(modules_path, "Modules")
|
|
10615
|
+
if candidate not in sys.path:
|
|
10616
|
+
sys.path.append(candidate)
|
|
10617
|
+
_RESOLVE_ENV_READY = True
|
|
10618
|
+
except Exception as exc:
|
|
10619
|
+
return None, f"Resolve scripting API unavailable: {exc}"
|
|
10620
|
+
try:
|
|
10621
|
+
import DaVinciResolveScript as dvr_script # type: ignore
|
|
10622
|
+
except Exception as exc:
|
|
10623
|
+
return None, f"Resolve scripting API unavailable: {exc}"
|
|
10624
|
+
try:
|
|
10625
|
+
resolve = dvr_script.scriptapp("Resolve")
|
|
10626
|
+
except Exception as exc:
|
|
10627
|
+
return None, f"Resolve connection failed: {exc}"
|
|
10628
|
+
if resolve is None:
|
|
10629
|
+
return None, "DaVinci Resolve is not connected. Open Resolve Studio with a project loaded."
|
|
10630
|
+
return resolve, None
|
|
10631
|
+
|
|
10632
|
+
|
|
10633
|
+
@_serialize_resolve
|
|
10634
|
+
def _current_resolve_project_id() -> Tuple[Optional[str], Optional[str]]:
|
|
10635
|
+
"""(project_id, error) for the currently-open Resolve project.
|
|
10636
|
+
|
|
10637
|
+
A handful of cheap API calls — used by the media-poll reuse path to detect
|
|
10638
|
+
when the user has switched projects in Resolve since the inventory was cached,
|
|
10639
|
+
without paying for a full Media Pool walk.
|
|
10640
|
+
"""
|
|
10641
|
+
resolve, error = _connect_resolve_read_only()
|
|
10642
|
+
if error or resolve is None:
|
|
10643
|
+
return None, error or "Resolve unavailable"
|
|
10644
|
+
pm, pm_error = _safe_call(resolve, "GetProjectManager")
|
|
10645
|
+
if not pm or pm_error:
|
|
10646
|
+
return None, pm_error or "Project manager unavailable"
|
|
10647
|
+
project, _ = _safe_call(pm, "GetCurrentProject")
|
|
10648
|
+
if not project:
|
|
10649
|
+
return None, "No Resolve project open"
|
|
10650
|
+
return _safe_id(project), None
|
|
10526
10651
|
|
|
10527
10652
|
|
|
10653
|
+
@_serialize_resolve
|
|
10528
10654
|
def _resolve_identity() -> Dict[str, Any]:
|
|
10529
10655
|
resolve, error = _connect_resolve_read_only()
|
|
10530
10656
|
if not resolve:
|
|
@@ -10555,7 +10681,64 @@ def _first_prop(props: Dict[str, Any], keys: Tuple[str, ...]) -> Any:
|
|
|
10555
10681
|
return None
|
|
10556
10682
|
|
|
10557
10683
|
|
|
10558
|
-
|
|
10684
|
+
# ── File-existence probing ──────────────────────────────────────────────────
|
|
10685
|
+
# stat() calls on mounted network storage dominate inventory time (300+ source
|
|
10686
|
+
# clips on a Z:\ share can take tens of seconds serially). We probe paths in a
|
|
10687
|
+
# thread pool and memoize results for a short TTL so the recurring media poll
|
|
10688
|
+
# does not re-stat unchanged paths every few seconds.
|
|
10689
|
+
_PATH_EXISTS_TTL = 60.0
|
|
10690
|
+
_PATH_PROBE_WORKERS = 16
|
|
10691
|
+
_PATH_EXISTS_CACHE: Dict[str, Tuple[float, bool]] = {}
|
|
10692
|
+
_PATH_EXISTS_LOCK = threading.Lock()
|
|
10693
|
+
|
|
10694
|
+
|
|
10695
|
+
def _cached_path_exists(path: str, now: float, ttl: float) -> Optional[bool]:
|
|
10696
|
+
with _PATH_EXISTS_LOCK:
|
|
10697
|
+
entry = _PATH_EXISTS_CACHE.get(path)
|
|
10698
|
+
if entry is not None and (now - entry[0]) <= ttl:
|
|
10699
|
+
return entry[1]
|
|
10700
|
+
return None
|
|
10701
|
+
|
|
10702
|
+
|
|
10703
|
+
def _store_path_exists(path: str, exists: bool, now: float) -> None:
|
|
10704
|
+
with _PATH_EXISTS_LOCK:
|
|
10705
|
+
_PATH_EXISTS_CACHE[path] = (now, exists)
|
|
10706
|
+
|
|
10707
|
+
|
|
10708
|
+
def _probe_paths_exist(paths: Any, *, probe: bool = True, ttl: float = _PATH_EXISTS_TTL) -> Dict[str, bool]:
|
|
10709
|
+
"""Resolve a collection of file paths to existence booleans.
|
|
10710
|
+
|
|
10711
|
+
With ``probe=True`` (first load / manual refresh) any cache entry older than
|
|
10712
|
+
``ttl`` is re-stat'd, and uncached paths are probed in parallel. With
|
|
10713
|
+
``probe=False`` (background poll) the filesystem is never touched: cached
|
|
10714
|
+
values are reused at any age and unknown paths fall back to ``True`` —
|
|
10715
|
+
Resolve's own online/offline Status property still flags clips it knows are
|
|
10716
|
+
missing, so we trust it rather than paying for a network round-trip on every
|
|
10717
|
+
poll.
|
|
10718
|
+
"""
|
|
10719
|
+
distinct = {str(p) for p in paths if p}
|
|
10720
|
+
result: Dict[str, bool] = {}
|
|
10721
|
+
to_probe: List[str] = []
|
|
10722
|
+
now = time.time()
|
|
10723
|
+
lookup_ttl = ttl if probe else float("inf")
|
|
10724
|
+
for path in distinct:
|
|
10725
|
+
cached = _cached_path_exists(path, now, lookup_ttl)
|
|
10726
|
+
if cached is not None:
|
|
10727
|
+
result[path] = cached
|
|
10728
|
+
elif probe:
|
|
10729
|
+
to_probe.append(path)
|
|
10730
|
+
else:
|
|
10731
|
+
result[path] = True
|
|
10732
|
+
if to_probe:
|
|
10733
|
+
workers = max(1, min(_PATH_PROBE_WORKERS, len(to_probe)))
|
|
10734
|
+
with ThreadPoolExecutor(max_workers=workers) as pool:
|
|
10735
|
+
for path, exists in zip(to_probe, pool.map(os.path.exists, to_probe)):
|
|
10736
|
+
result[path] = bool(exists)
|
|
10737
|
+
_store_path_exists(path, bool(exists), now)
|
|
10738
|
+
return result
|
|
10739
|
+
|
|
10740
|
+
|
|
10741
|
+
def _media_status(props: Dict[str, Any], file_path: Optional[str], *, file_exists: Optional[bool] = None) -> str:
|
|
10559
10742
|
status_text = str(_first_prop(props, ("Status", "Media Status", "Online Status", "Offline")) or "").strip().lower()
|
|
10560
10743
|
if not file_path:
|
|
10561
10744
|
return "no_path"
|
|
@@ -10563,7 +10746,11 @@ def _media_status(props: Dict[str, Any], file_path: Optional[str]) -> str:
|
|
|
10563
10746
|
return "offline"
|
|
10564
10747
|
if "missing" in status_text:
|
|
10565
10748
|
return "missing_file"
|
|
10566
|
-
|
|
10749
|
+
# Pass `file_exists` in to reuse a single os.path.exists() probe — stat calls on
|
|
10750
|
+
# network source media are slow, so the caller avoids probing the same path twice.
|
|
10751
|
+
if file_exists is None:
|
|
10752
|
+
file_exists = os.path.exists(str(file_path))
|
|
10753
|
+
if not file_exists:
|
|
10567
10754
|
return "missing_file"
|
|
10568
10755
|
return "online"
|
|
10569
10756
|
|
|
@@ -10644,9 +10831,18 @@ def _resolve_clip_record(clip: Any, bin_path: str, selected_ids: set) -> Dict[st
|
|
|
10644
10831
|
"resolve_status": _first_prop(props, ("Status", "Media Status", "Online Status", "Offline")),
|
|
10645
10832
|
"selected": bool(clip_id and clip_id in selected_ids),
|
|
10646
10833
|
}
|
|
10647
|
-
|
|
10648
|
-
|
|
10834
|
+
# File-existence is resolved in a single parallel batch after every clip's
|
|
10835
|
+
# Resolve properties are gathered (see resolve_media_inventory), so we stash
|
|
10836
|
+
# the props and defer existence-dependent fields to _finalize_clip_record.
|
|
10649
10837
|
record["clip_key"] = stable_clip_directory(record)
|
|
10838
|
+
record["_props"] = props
|
|
10839
|
+
return record
|
|
10840
|
+
|
|
10841
|
+
|
|
10842
|
+
def _finalize_clip_record(record: Dict[str, Any], file_exists: bool) -> Dict[str, Any]:
|
|
10843
|
+
props = record.pop("_props", {}) or {}
|
|
10844
|
+
record["file_exists"] = file_exists
|
|
10845
|
+
record["status"] = _media_status(props, record["file_path"], file_exists=file_exists)
|
|
10650
10846
|
record["source_clip"], record["source_clip_reason"] = _source_clip_status(record, props)
|
|
10651
10847
|
record["analyzable"], record["analyzable_reason"] = _analyzable_clip_status(record, props)
|
|
10652
10848
|
return record
|
|
@@ -10786,77 +10982,36 @@ def _analysis_status_by_clip(project_root: str, records: List[Dict[str, Any]]) -
|
|
|
10786
10982
|
return status_by_key
|
|
10787
10983
|
|
|
10788
10984
|
|
|
10789
|
-
|
|
10790
|
-
|
|
10791
|
-
|
|
10792
|
-
|
|
10793
|
-
|
|
10794
|
-
resolve, resolve_error = _connect_resolve_read_only()
|
|
10795
|
-
if resolve_error:
|
|
10796
|
-
return {
|
|
10797
|
-
"success": True,
|
|
10798
|
-
"resolve_available": False,
|
|
10799
|
-
"status": "Resolve unavailable",
|
|
10800
|
-
"error": resolve_error,
|
|
10801
|
-
"clips": [],
|
|
10802
|
-
"counts": _empty_media_counts(),
|
|
10803
|
-
}
|
|
10804
|
-
pm, pm_error = _safe_call(resolve, "GetProjectManager")
|
|
10805
|
-
project = None
|
|
10806
|
-
if pm and not pm_error:
|
|
10807
|
-
project, _ = _safe_call(pm, "GetCurrentProject")
|
|
10808
|
-
if not project:
|
|
10809
|
-
return {
|
|
10810
|
-
"success": True,
|
|
10811
|
-
"resolve_available": False,
|
|
10812
|
-
"status": "No Resolve project",
|
|
10813
|
-
"error": "DaVinci Resolve is connected, but no project is open.",
|
|
10814
|
-
"clips": [],
|
|
10815
|
-
"counts": _empty_media_counts(),
|
|
10816
|
-
}
|
|
10817
|
-
media_pool, mp_error = _safe_call(project, "GetMediaPool")
|
|
10818
|
-
if not media_pool or mp_error:
|
|
10819
|
-
return {
|
|
10820
|
-
"success": True,
|
|
10821
|
-
"resolve_available": False,
|
|
10822
|
-
"status": "Media Pool unavailable",
|
|
10823
|
-
"error": mp_error or "Failed to get Resolve Media Pool",
|
|
10824
|
-
"clips": [],
|
|
10825
|
-
"counts": _empty_media_counts(),
|
|
10826
|
-
}
|
|
10827
|
-
root_folder, root_error = _safe_call(media_pool, "GetRootFolder")
|
|
10828
|
-
if not root_folder or root_error:
|
|
10829
|
-
return {
|
|
10830
|
-
"success": True,
|
|
10831
|
-
"resolve_available": False,
|
|
10832
|
-
"status": "Root folder unavailable",
|
|
10833
|
-
"error": root_error or "Failed to get Resolve root folder",
|
|
10834
|
-
"clips": [],
|
|
10835
|
-
"counts": _empty_media_counts(),
|
|
10836
|
-
}
|
|
10985
|
+
# Last full Resolve walk per project_root, kept overlay-free so the analysis
|
|
10986
|
+
# status can be re-applied cheaply on every background poll without re-walking
|
|
10987
|
+
# the Media Pool (the expensive, non-parallelizable GetClipProperty pass).
|
|
10988
|
+
_INVENTORY_CACHE: Dict[str, Dict[str, Any]] = {}
|
|
10989
|
+
_INVENTORY_LOCK = threading.Lock()
|
|
10837
10990
|
|
|
10838
|
-
|
|
10839
|
-
|
|
10840
|
-
|
|
10841
|
-
|
|
10842
|
-
|
|
10843
|
-
|
|
10844
|
-
|
|
10845
|
-
|
|
10846
|
-
|
|
10847
|
-
|
|
10848
|
-
|
|
10849
|
-
|
|
10850
|
-
|
|
10851
|
-
|
|
10852
|
-
|
|
10853
|
-
|
|
10854
|
-
|
|
10855
|
-
|
|
10991
|
+
|
|
10992
|
+
def _get_cached_inventory(project_root: str) -> Optional[Dict[str, Any]]:
|
|
10993
|
+
with _INVENTORY_LOCK:
|
|
10994
|
+
return _INVENTORY_CACHE.get(project_root)
|
|
10995
|
+
|
|
10996
|
+
|
|
10997
|
+
def _store_cached_inventory(project_root: str, entry: Dict[str, Any]) -> None:
|
|
10998
|
+
with _INVENTORY_LOCK:
|
|
10999
|
+
_INVENTORY_CACHE[project_root] = entry
|
|
11000
|
+
|
|
11001
|
+
|
|
11002
|
+
def _assemble_inventory_payload(project_root: str, entry: Dict[str, Any]) -> Dict[str, Any]:
|
|
11003
|
+
"""Apply the (local, cheap) analysis-status overlay onto cached base records.
|
|
11004
|
+
|
|
11005
|
+
Base records hold the Resolve-derived fields plus file existence; the analysis
|
|
11006
|
+
overlay (queued/running/analyzed, report paths, job ids) is re-read from disk
|
|
11007
|
+
every call so a background poll reflects job progress without touching Resolve.
|
|
11008
|
+
Records are copied so the cached base stays overlay-free across polls.
|
|
11009
|
+
"""
|
|
11010
|
+
records = [dict(record) for record in entry["base_records"]]
|
|
10856
11011
|
status_by_key = _analysis_status_by_clip(project_root, records)
|
|
10857
11012
|
counts = _empty_media_counts()
|
|
10858
11013
|
counts["total"] = len(records)
|
|
10859
|
-
counts["selected"] =
|
|
11014
|
+
counts["selected"] = entry.get("selected_count", sum(1 for r in records if r.get("selected")))
|
|
10860
11015
|
for record in records:
|
|
10861
11016
|
status = record.get("status") or "unknown"
|
|
10862
11017
|
if status in counts:
|
|
@@ -10879,17 +11034,140 @@ def resolve_media_inventory(project_root: str, *, limit: Any = 500, recursive: b
|
|
|
10879
11034
|
"success": True,
|
|
10880
11035
|
"resolve_available": True,
|
|
10881
11036
|
"status": "Resolve connected",
|
|
10882
|
-
"project":
|
|
10883
|
-
"name": _safe_name(project, "Resolve Project"),
|
|
10884
|
-
"id": _safe_id(project),
|
|
10885
|
-
},
|
|
11037
|
+
"project": entry["project"],
|
|
10886
11038
|
"project_root": project_root,
|
|
10887
11039
|
"clips": records,
|
|
10888
11040
|
"counts": counts,
|
|
11041
|
+
"truncated": bool(entry.get("truncated")),
|
|
11042
|
+
"limit": entry.get("limit"),
|
|
11043
|
+
"warnings": entry.get("warnings", []),
|
|
11044
|
+
}
|
|
11045
|
+
|
|
11046
|
+
|
|
11047
|
+
def resolve_media_inventory(
|
|
11048
|
+
project_root: str,
|
|
11049
|
+
*,
|
|
11050
|
+
limit: Any = 500,
|
|
11051
|
+
recursive: bool = True,
|
|
11052
|
+
probe_paths: bool = True,
|
|
11053
|
+
reuse_cached: bool = False,
|
|
11054
|
+
) -> Dict[str, Any]:
|
|
11055
|
+
try:
|
|
11056
|
+
max_items = max(1, min(int(limit), 2000))
|
|
11057
|
+
except (TypeError, ValueError):
|
|
11058
|
+
max_items = 500
|
|
11059
|
+
|
|
11060
|
+
# Background polls only need to surface analysis progress (a local, disk-backed
|
|
11061
|
+
# signal), so they reuse the last Resolve walk instead of paying for ~N serial
|
|
11062
|
+
# GetClipProperty round-trips again. A cheap project-id check still catches a
|
|
11063
|
+
# project switch made directly in Resolve (a handful of API calls vs a full
|
|
11064
|
+
# walk); we rebuild only on a confirmed mismatch. If the current project can't
|
|
11065
|
+
# be determined (Resolve down / no project open), we keep serving the cache —
|
|
11066
|
+
# a transient blip shouldn't trigger an expensive rebuild on every poll.
|
|
11067
|
+
if reuse_cached:
|
|
11068
|
+
cached = _get_cached_inventory(project_root)
|
|
11069
|
+
if cached is not None:
|
|
11070
|
+
current_id, id_error = _current_resolve_project_id()
|
|
11071
|
+
cached_id = (cached.get("project") or {}).get("id")
|
|
11072
|
+
project_changed = (
|
|
11073
|
+
id_error is None
|
|
11074
|
+
and current_id is not None
|
|
11075
|
+
and str(current_id) != str(cached_id)
|
|
11076
|
+
)
|
|
11077
|
+
if not project_changed:
|
|
11078
|
+
return _assemble_inventory_payload(project_root, cached)
|
|
11079
|
+
|
|
11080
|
+
# Everything that touches the Resolve scripting API stays under the lock; the
|
|
11081
|
+
# parallel path probe and the disk overlay run outside it.
|
|
11082
|
+
with _RESOLVE_API_LOCK:
|
|
11083
|
+
resolve, resolve_error = _connect_resolve_read_only()
|
|
11084
|
+
if resolve_error:
|
|
11085
|
+
return {
|
|
11086
|
+
"success": True,
|
|
11087
|
+
"resolve_available": False,
|
|
11088
|
+
"status": "Resolve unavailable",
|
|
11089
|
+
"error": resolve_error,
|
|
11090
|
+
"clips": [],
|
|
11091
|
+
"counts": _empty_media_counts(),
|
|
11092
|
+
}
|
|
11093
|
+
pm, pm_error = _safe_call(resolve, "GetProjectManager")
|
|
11094
|
+
project = None
|
|
11095
|
+
if pm and not pm_error:
|
|
11096
|
+
project, _ = _safe_call(pm, "GetCurrentProject")
|
|
11097
|
+
if not project:
|
|
11098
|
+
return {
|
|
11099
|
+
"success": True,
|
|
11100
|
+
"resolve_available": False,
|
|
11101
|
+
"status": "No Resolve project",
|
|
11102
|
+
"error": "DaVinci Resolve is connected, but no project is open.",
|
|
11103
|
+
"clips": [],
|
|
11104
|
+
"counts": _empty_media_counts(),
|
|
11105
|
+
}
|
|
11106
|
+
media_pool, mp_error = _safe_call(project, "GetMediaPool")
|
|
11107
|
+
if not media_pool or mp_error:
|
|
11108
|
+
return {
|
|
11109
|
+
"success": True,
|
|
11110
|
+
"resolve_available": False,
|
|
11111
|
+
"status": "Media Pool unavailable",
|
|
11112
|
+
"error": mp_error or "Failed to get Resolve Media Pool",
|
|
11113
|
+
"clips": [],
|
|
11114
|
+
"counts": _empty_media_counts(),
|
|
11115
|
+
}
|
|
11116
|
+
root_folder, root_error = _safe_call(media_pool, "GetRootFolder")
|
|
11117
|
+
if not root_folder or root_error:
|
|
11118
|
+
return {
|
|
11119
|
+
"success": True,
|
|
11120
|
+
"resolve_available": False,
|
|
11121
|
+
"status": "Root folder unavailable",
|
|
11122
|
+
"error": root_error or "Failed to get Resolve root folder",
|
|
11123
|
+
"clips": [],
|
|
11124
|
+
"counts": _empty_media_counts(),
|
|
11125
|
+
}
|
|
11126
|
+
|
|
11127
|
+
selected_ids = set()
|
|
11128
|
+
selected_clips, _ = _safe_call(media_pool, "GetSelectedClips")
|
|
11129
|
+
for clip in selected_clips or []:
|
|
11130
|
+
clip_id = _safe_id(clip)
|
|
11131
|
+
if clip_id:
|
|
11132
|
+
selected_ids.add(clip_id)
|
|
11133
|
+
|
|
11134
|
+
warnings: List[str] = []
|
|
11135
|
+
records: List[Dict[str, Any]] = []
|
|
11136
|
+
truncated = _append_folder_media(
|
|
11137
|
+
root_folder,
|
|
11138
|
+
bin_path="Master",
|
|
11139
|
+
recursive=recursive,
|
|
11140
|
+
selected_ids=selected_ids,
|
|
11141
|
+
records=records,
|
|
11142
|
+
warnings=warnings,
|
|
11143
|
+
limit=max_items,
|
|
11144
|
+
)
|
|
11145
|
+
project_info = {
|
|
11146
|
+
"name": _safe_name(project, "Resolve Project"),
|
|
11147
|
+
"id": _safe_id(project),
|
|
11148
|
+
}
|
|
11149
|
+
selected_count = len(selected_ids)
|
|
11150
|
+
|
|
11151
|
+
# Resolve every clip's file path in one parallel, cache-backed batch, then
|
|
11152
|
+
# finalize existence-dependent fields (status / analyzable).
|
|
11153
|
+
existence = _probe_paths_exist(
|
|
11154
|
+
(record.get("file_path") for record in records),
|
|
11155
|
+
probe=probe_paths,
|
|
11156
|
+
)
|
|
11157
|
+
for record in records:
|
|
11158
|
+
file_path = record.get("file_path")
|
|
11159
|
+
_finalize_clip_record(record, bool(file_path) and existence.get(str(file_path), False))
|
|
11160
|
+
|
|
11161
|
+
entry = {
|
|
11162
|
+
"base_records": records,
|
|
11163
|
+
"project": project_info,
|
|
11164
|
+
"selected_count": selected_count,
|
|
10889
11165
|
"truncated": bool(truncated),
|
|
10890
11166
|
"limit": max_items,
|
|
10891
11167
|
"warnings": warnings,
|
|
10892
11168
|
}
|
|
11169
|
+
_store_cached_inventory(project_root, entry)
|
|
11170
|
+
return _assemble_inventory_payload(project_root, entry)
|
|
10893
11171
|
|
|
10894
11172
|
|
|
10895
11173
|
_PROJECT_CONTEXT_RE = re.compile(r"^(?P<slug>.+)-(?P<hash>[0-9a-f]{10})$")
|
|
@@ -10941,6 +11219,7 @@ def _context_from_project_root(base_root: str, project_root: str, *, source: str
|
|
|
10941
11219
|
}
|
|
10942
11220
|
|
|
10943
11221
|
|
|
11222
|
+
@_serialize_resolve
|
|
10944
11223
|
def _current_resolve_project_context(base_root: str) -> Optional[Dict[str, Any]]:
|
|
10945
11224
|
resolve, resolve_error = _connect_resolve_read_only()
|
|
10946
11225
|
if resolve_error:
|
|
@@ -11001,6 +11280,7 @@ def _project_folder_label(folder_path: List[str]) -> str:
|
|
|
11001
11280
|
return " / ".join(folder_path) if folder_path else "Root"
|
|
11002
11281
|
|
|
11003
11282
|
|
|
11283
|
+
@_serialize_resolve
|
|
11004
11284
|
def _resolve_all_project_contexts(base_root: str, *, max_depth: int = 12, max_projects: int = 2000) -> Dict[str, Any]:
|
|
11005
11285
|
resolve, resolve_error = _connect_resolve_read_only()
|
|
11006
11286
|
if resolve_error:
|
|
@@ -11123,6 +11403,7 @@ def _resolve_all_project_contexts(base_root: str, *, max_depth: int = 12, max_pr
|
|
|
11123
11403
|
}
|
|
11124
11404
|
|
|
11125
11405
|
|
|
11406
|
+
@_serialize_resolve
|
|
11126
11407
|
def _resolve_project_contexts(base_root: str) -> Dict[str, Any]:
|
|
11127
11408
|
resolve, resolve_error = _connect_resolve_read_only()
|
|
11128
11409
|
if resolve_error:
|
|
@@ -11196,6 +11477,7 @@ def _resolve_project_contexts(base_root: str) -> Dict[str, Any]:
|
|
|
11196
11477
|
}
|
|
11197
11478
|
|
|
11198
11479
|
|
|
11480
|
+
@_serialize_resolve
|
|
11199
11481
|
def _load_resolve_project_context(base_root: str, project_name: Any, folder_path: Any = None) -> Dict[str, Any]:
|
|
11200
11482
|
target_name = str(project_name or "").strip()
|
|
11201
11483
|
if not target_name:
|
|
@@ -12960,6 +13242,27 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
12960
13242
|
self.end_headers()
|
|
12961
13243
|
self.wfile.write(raw)
|
|
12962
13244
|
|
|
13245
|
+
def _json_etag(self, payload: Dict[str, Any]) -> None:
|
|
13246
|
+
"""JSON response with an ETag so unchanged polls short-circuit to 304.
|
|
13247
|
+
|
|
13248
|
+
The Resolve media inventory is re-fetched every few seconds; when the
|
|
13249
|
+
serialized payload is byte-identical to what the client already holds we
|
|
13250
|
+
skip both the body transfer and the client-side re-render of the table.
|
|
13251
|
+
"""
|
|
13252
|
+
raw = json.dumps(payload, ensure_ascii=False, default=str).encode("utf-8")
|
|
13253
|
+
etag = '"' + hashlib.md5(raw).hexdigest() + '"'
|
|
13254
|
+
if self.headers.get("If-None-Match") == etag:
|
|
13255
|
+
self.send_response(HTTPStatus.NOT_MODIFIED)
|
|
13256
|
+
self.send_header("ETag", etag)
|
|
13257
|
+
self.end_headers()
|
|
13258
|
+
return
|
|
13259
|
+
self.send_response(200)
|
|
13260
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
13261
|
+
self.send_header("Content-Length", str(len(raw)))
|
|
13262
|
+
self.send_header("ETag", etag)
|
|
13263
|
+
self.end_headers()
|
|
13264
|
+
self.wfile.write(raw)
|
|
13265
|
+
|
|
12963
13266
|
def _serve_file(self, path: str, content_type: str = "application/octet-stream") -> None:
|
|
12964
13267
|
try:
|
|
12965
13268
|
with open(path, "rb") as handle:
|
|
@@ -13089,11 +13392,13 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
13089
13392
|
self._json(_setup_defaults("get_defaults"))
|
|
13090
13393
|
return
|
|
13091
13394
|
if path == "/api/resolve/media":
|
|
13092
|
-
self.
|
|
13395
|
+
self._json_etag(
|
|
13093
13396
|
resolve_media_inventory(
|
|
13094
13397
|
self.state.project_root,
|
|
13095
13398
|
limit=(query.get("limit") or [500])[0],
|
|
13096
13399
|
recursive=(query.get("recursive") or ["true"])[0].lower() not in {"0", "false", "no"},
|
|
13400
|
+
probe_paths=(query.get("probe") or ["1"])[0].lower() not in {"0", "false", "no"},
|
|
13401
|
+
reuse_cached=(query.get("reuse") or ["0"])[0].lower() in {"1", "true", "yes"},
|
|
13097
13402
|
)
|
|
13098
13403
|
)
|
|
13099
13404
|
return
|
|
@@ -13556,6 +13861,20 @@ def parse_args() -> argparse.Namespace:
|
|
|
13556
13861
|
return parser.parse_args()
|
|
13557
13862
|
|
|
13558
13863
|
|
|
13864
|
+
def _warm_inventory_cache(project_root: str) -> None:
|
|
13865
|
+
"""Build the first Resolve inventory in the background at startup.
|
|
13866
|
+
|
|
13867
|
+
Populates the inventory + path-existence caches before the browser connects so
|
|
13868
|
+
the first dashboard open paints live data immediately instead of waiting on a
|
|
13869
|
+
cold Media Pool walk. Best-effort: if Resolve isn't up yet this no-ops and the
|
|
13870
|
+
first real request builds normally.
|
|
13871
|
+
"""
|
|
13872
|
+
try:
|
|
13873
|
+
resolve_media_inventory(project_root)
|
|
13874
|
+
except Exception: # noqa: BLE001 — warm-up must never crash startup
|
|
13875
|
+
pass
|
|
13876
|
+
|
|
13877
|
+
|
|
13559
13878
|
def main() -> None:
|
|
13560
13879
|
args = parse_args()
|
|
13561
13880
|
state = DashboardState(args.project_name, args.project_id, args.analysis_root)
|
|
@@ -13564,6 +13883,7 @@ def main() -> None:
|
|
|
13564
13883
|
url = f"http://{args.host}:{args.port}"
|
|
13565
13884
|
print(f"DaVinci Resolve MCP: {url}")
|
|
13566
13885
|
print(f"Project analysis root: {state.project_root}")
|
|
13886
|
+
threading.Thread(target=_warm_inventory_cache, args=(state.project_root,), daemon=True).start()
|
|
13567
13887
|
if args.open:
|
|
13568
13888
|
webbrowser.open(url)
|
|
13569
13889
|
try:
|
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.27.
|
|
83
|
+
VERSION = "2.27.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()}")
|