davinci-resolve-mcp 2.27.0 → 2.27.2
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 +71 -0
- package/README.md +1 -1
- package/install.py +1 -1
- package/package.json +1 -1
- package/src/analysis_dashboard.py +506 -131
- package/src/granular/common.py +1 -1
- package/src/server.py +1 -1
- package/src/utils/media_analysis.py +214 -5
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,77 @@
|
|
|
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.2
|
|
6
|
+
|
|
7
|
+
**Control panel under-counted analyzed clips after a Media Pool rename (issue
|
|
8
|
+
#51)** — with every clip analyzed (e.g. 303/303 reports on disk), the overview
|
|
9
|
+
and Media tab could report something like "108 / 303 analyzed". The panel only
|
|
10
|
+
recognized a report when a folder's name exactly matched the clip's *current*
|
|
11
|
+
display name, so renaming clips after analysis hid their existing reports even
|
|
12
|
+
though the underlying media was unchanged.
|
|
13
|
+
|
|
14
|
+
Root cause and fix:
|
|
15
|
+
|
|
16
|
+
- **Lookups are keyed by a rename-stable hash, not the display-name folder.**
|
|
17
|
+
Report folders are named `<display-slug>-<hash>`; the count now matches on the
|
|
18
|
+
trailing hash (and the ids inside each report), so a renamed clip still
|
|
19
|
+
resolves to its existing folder. Both the disk path and the jobs-DB fallback
|
|
20
|
+
were corrected.
|
|
21
|
+
- **The hash is now anchored to the normalized file path (canonical basis).**
|
|
22
|
+
Previously the basis was a `clip_id`-first cascade, so the same media hashed
|
|
23
|
+
differently depending on which fields a record carried — Resolve inventory
|
|
24
|
+
(clip_id) vs path-based batch jobs (file path) disagreed on the same clip.
|
|
25
|
+
Anchoring to the file path removes that cross-basis mismatch. Legacy folders
|
|
26
|
+
(clip_id-based, or raw-path-based) still resolve via a migration-safe set of
|
|
27
|
+
candidate hashes, so **no on-disk migration is required**.
|
|
28
|
+
- **Writes reuse an existing report folder** (matched by canonical or legacy
|
|
29
|
+
hash) instead of minting a new `<newslug>-<hash>` directory, eliminating
|
|
30
|
+
orphaned duplicate folders when a renamed clip is re-analyzed.
|
|
31
|
+
- **A persisted clip index (`clips/index.json`)** maps every stable id found in
|
|
32
|
+
a report (normalized + raw file path, clip_id, media_id) to its folder, so the
|
|
33
|
+
count can still match a clip by any id it carries — including an offline clip
|
|
34
|
+
that no longer reports a file path but retains its clip_id. The index is
|
|
35
|
+
refreshed only when a report is added, removed, or rewritten (cheap signature
|
|
36
|
+
check), so the recurring poll stays inexpensive.
|
|
37
|
+
|
|
38
|
+
No public MCP tool surface changed. Adds regression tests in
|
|
39
|
+
`tests/test_media_analysis.py` covering rename, cross-basis, legacy-folder reuse,
|
|
40
|
+
the jobs-DB fallback, and the offline/no-path case.
|
|
41
|
+
|
|
42
|
+
## What's New in v2.27.1
|
|
43
|
+
|
|
44
|
+
**Faster control-panel startup with network source media (issue #50)** — on
|
|
45
|
+
first open the control panel could sit on "connection pending" for a long time
|
|
46
|
+
when Media Pool clips lived on mounted network storage, because the UI only
|
|
47
|
+
treated the connection as live once the full media inventory finished loading,
|
|
48
|
+
and that inventory probed every clip's file path on disk.
|
|
49
|
+
|
|
50
|
+
Fixes and performance work:
|
|
51
|
+
|
|
52
|
+
- **Connection state is decoupled from the media inventory.** The overview and
|
|
53
|
+
diagnostics panels now derive "connected" from the `/api/boot` handshake (which
|
|
54
|
+
returns as soon as the Resolve bridge is reachable) and show inventory loading
|
|
55
|
+
separately, so Resolve reads as live immediately while clips stream in.
|
|
56
|
+
- **Parallel, cached file-existence probing.** `os.path.exists` for every clip
|
|
57
|
+
now runs in a thread pool and is memoized for a short TTL, instead of two serial
|
|
58
|
+
`stat()` calls per clip — the dominant cost on network storage.
|
|
59
|
+
- **Background polls reuse the cached Media Pool walk.** The recurring poll no
|
|
60
|
+
longer re-runs the ~N serial `GetClipProperty` calls; it reuses the last walk
|
|
61
|
+
and re-applies only the local, disk-backed analysis-status overlay. A cheap
|
|
62
|
+
project-id check still catches a project switched directly in Resolve, and a
|
|
63
|
+
manual refresh always does a full walk.
|
|
64
|
+
- **Resolve scripting API access is serialized.** A re-entrant lock guards every
|
|
65
|
+
scripting-API entry point, since the dashboard's threaded HTTP server could
|
|
66
|
+
previously fire concurrent (thread-unsafe) Resolve calls at startup.
|
|
67
|
+
- **ETag/304 on the inventory endpoint** skips transfer and table re-render when
|
|
68
|
+
nothing changed; the last good inventory is cached client-side and painted
|
|
69
|
+
instantly on reload; and the first inventory build is warmed in a background
|
|
70
|
+
thread at server start.
|
|
71
|
+
|
|
72
|
+
No public MCP tool surface changed. Adds regression tests in
|
|
73
|
+
`tests/test_media_analysis.py` (path-existence probing, inventory cache reuse,
|
|
74
|
+
project-switch detection, lock reentrancy).
|
|
75
|
+
|
|
5
76
|
## What's New in v2.27.0
|
|
6
77
|
|
|
7
78
|
**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.2"
|
|
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
|
|
@@ -23,7 +27,10 @@ from src.utils.media_analysis import (
|
|
|
23
27
|
detect_capabilities,
|
|
24
28
|
query_analysis_index,
|
|
25
29
|
resolve_output_root,
|
|
30
|
+
clip_directory_hash,
|
|
31
|
+
load_clip_index,
|
|
26
32
|
stable_clip_directory,
|
|
33
|
+
stable_clip_match_hashes,
|
|
27
34
|
)
|
|
28
35
|
from src.utils.media_analysis_jobs import (
|
|
29
36
|
MEDIA_EXTENSIONS,
|
|
@@ -4636,6 +4643,8 @@ HTML = r"""<!doctype html>
|
|
|
4636
4643
|
allProjects: null,
|
|
4637
4644
|
activeContext: null,
|
|
4638
4645
|
resolveMedia: null,
|
|
4646
|
+
resolveMediaStale: false,
|
|
4647
|
+
mediaETag: null,
|
|
4639
4648
|
mediaPollTimer: null,
|
|
4640
4649
|
mediaRefreshing: false,
|
|
4641
4650
|
mediaLastRefresh: null,
|
|
@@ -5032,18 +5041,34 @@ HTML = r"""<!doctype html>
|
|
|
5032
5041
|
const clips = sourceClips();
|
|
5033
5042
|
const hiddenRecords = Math.max(0, Number(counts.total || 0) - Number(counts.source_clips || clips.length));
|
|
5034
5043
|
const sequences = sequenceCount(media);
|
|
5044
|
+
// Connection state comes from the /api/boot handshake, which returns as soon
|
|
5045
|
+
// as the Resolve bridge is reachable — independent of the media inventory,
|
|
5046
|
+
// which can take a long time to probe network source media. inventoryPending
|
|
5047
|
+
// means Resolve is live but /api/resolve/media hasn't returned yet.
|
|
5048
|
+
const bootResolve = state.boot?.resolve || {};
|
|
5049
|
+
const resolveConnected = bootResolve.available === true || !!media?.resolve_available;
|
|
5050
|
+
const inventoryPending = resolveConnected && !media;
|
|
5035
5051
|
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
|
-
|
|
5052
|
+
const resolveProject = media?.project?.name || (resolveConnected ? 'Loading project…' : 'No Resolve project');
|
|
5053
|
+
let resolveStatus;
|
|
5054
|
+
if (media?.resolve_available) {
|
|
5055
|
+
resolveStatus = `Resolve: ${resolveProject} · read-only`;
|
|
5056
|
+
} else if (inventoryPending) {
|
|
5057
|
+
resolveStatus = 'Resolve connected · loading inventory…';
|
|
5058
|
+
} else if (resolveConnected) {
|
|
5059
|
+
resolveStatus = media?.status || media?.error || 'Resolve connected';
|
|
5060
|
+
} else {
|
|
5061
|
+
resolveStatus = media?.status || bootResolve.error || 'Connection pending';
|
|
5062
|
+
}
|
|
5038
5063
|
const index = indexSummary();
|
|
5039
5064
|
const readyClips = clips.filter(clip => clip.analyzable).length;
|
|
5040
5065
|
const analyzedClips = clips.filter(clip => ['analyzed', 'succeeded', 'skipped'].includes(String(clip.analysis_status || ''))).length;
|
|
5041
5066
|
const onlineClips = clips.filter(clip => String(clip.status || '') === 'online').length;
|
|
5042
5067
|
const missingClips = clips.filter(clip => ['missing_file', 'offline'].includes(String(clip.status || ''))).length;
|
|
5043
|
-
const mediaStatusLabel = media?.resolve_available ? `${onlineClips} online` : 'Unavailable';
|
|
5068
|
+
const mediaStatusLabel = media?.resolve_available ? `${onlineClips} online` : (inventoryPending ? 'Loading…' : 'Unavailable');
|
|
5044
5069
|
const mediaStatusDetail = media?.resolve_available
|
|
5045
5070
|
? `${missingClips} missing/offline · ${hiddenRecords} non-source records`
|
|
5046
|
-
: (media?.error || media?.status || 'Resolve inventory pending');
|
|
5071
|
+
: (inventoryPending ? 'Reading Media Pool inventory…' : (media?.error || media?.status || 'Resolve inventory pending'));
|
|
5047
5072
|
|
|
5048
5073
|
setText('overviewUpdated', `Updated ${new Date().toLocaleTimeString()}`);
|
|
5049
5074
|
setText('overviewProject', projectName);
|
|
@@ -5056,7 +5081,10 @@ HTML = r"""<!doctype html>
|
|
|
5056
5081
|
setText('overviewMediaStatusDetail', mediaStatusDetail);
|
|
5057
5082
|
|
|
5058
5083
|
if (!media?.resolve_available) {
|
|
5059
|
-
|
|
5084
|
+
const emptyMsg = inventoryPending
|
|
5085
|
+
? 'Resolve connected — loading Media Pool inventory…'
|
|
5086
|
+
: (media?.error || 'Open Resolve with a project loaded to inspect clips.');
|
|
5087
|
+
setHtml('overviewStatusList', `<div class="empty">${escapeHtml(emptyMsg)}</div>`);
|
|
5060
5088
|
} else {
|
|
5061
5089
|
const ICONS = {
|
|
5062
5090
|
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 +5137,13 @@ HTML = r"""<!doctype html>
|
|
|
5109
5137
|
const index = indexSummary();
|
|
5110
5138
|
const resolveInfo = state.boot?.resolve || {};
|
|
5111
5139
|
const warnings = media?.warnings || [];
|
|
5112
|
-
|
|
5113
|
-
const
|
|
5114
|
-
const
|
|
5140
|
+
// The boot handshake establishes the connection; media inventory loads after.
|
|
5141
|
+
const handshake = resolveInfo.available === true;
|
|
5142
|
+
const resolveOnline = !!media?.resolve_available || handshake;
|
|
5143
|
+
const inventoryPending = handshake && !media;
|
|
5144
|
+
const offline = (media || resolveInfo.error) && !resolveOnline;
|
|
5145
|
+
const resolveTone = resolveOnline ? 'pill-ok' : (offline ? 'pill-err' : 'pill-mute');
|
|
5146
|
+
const resolveLabel = resolveOnline ? 'Connected' : (offline ? 'Offline' : 'Pending');
|
|
5115
5147
|
|
|
5116
5148
|
const connectionCard = `
|
|
5117
5149
|
<div class="diag-card">
|
|
@@ -5123,7 +5155,7 @@ HTML = r"""<!doctype html>
|
|
|
5123
5155
|
${diagRow('Product', resolveInfo.product || (resolveOnline ? 'DaVinci Resolve' : 'Unavailable'), { muted: !resolveOnline })}
|
|
5124
5156
|
${diagRow('Version', resolveInfo.version_string || '—', { muted: !resolveInfo.version_string })}
|
|
5125
5157
|
${diagRow('Active page', resolveInfo.page || '—', { muted: !resolveInfo.page })}
|
|
5126
|
-
${diagRow('Status', resolveOnline ? 'Read-only API live' : (media?.error || media?.status || 'Waiting for handshake'))}
|
|
5158
|
+
${diagRow('Status', inventoryPending ? 'Read-only API live · loading inventory…' : (resolveOnline ? 'Read-only API live' : (media?.error || resolveInfo.error || media?.status || 'Waiting for handshake')))}
|
|
5127
5159
|
</div>
|
|
5128
5160
|
</div>`;
|
|
5129
5161
|
|
|
@@ -5926,6 +5958,14 @@ HTML = r"""<!doctype html>
|
|
|
5926
5958
|
syncPreferencesPanel();
|
|
5927
5959
|
applyPreferencesToControls(prefs);
|
|
5928
5960
|
renderVersionBadge();
|
|
5961
|
+
// Paint the previous inventory immediately (stale) so a slow first
|
|
5962
|
+
// /api/resolve/media — common with network source media — doesn't leave the
|
|
5963
|
+
// panel blank. The live fetch below replaces it and clears the stale flag.
|
|
5964
|
+
const cachedInventory = loadInventorySnapshot();
|
|
5965
|
+
if (cachedInventory) {
|
|
5966
|
+
state.resolveMediaStale = true;
|
|
5967
|
+
renderResolveMedia(cachedInventory);
|
|
5968
|
+
}
|
|
5929
5969
|
await refreshProjectContexts();
|
|
5930
5970
|
renderControlPanels();
|
|
5931
5971
|
await refreshIndex();
|
|
@@ -6057,15 +6097,56 @@ HTML = r"""<!doctype html>
|
|
|
6057
6097
|
state.mediaRefreshing = true;
|
|
6058
6098
|
updateMediaPollStatus('refreshing');
|
|
6059
6099
|
try {
|
|
6060
|
-
|
|
6100
|
+
// Background polls reuse the cached Resolve walk and skip the network FS
|
|
6101
|
+
// probe (the server re-applies only the local analysis overlay); manual /
|
|
6102
|
+
// first loads do a full Media Pool walk with a fresh probe. The ETag lets
|
|
6103
|
+
// an unchanged poll short-circuit to 304 and skip the table re-render.
|
|
6104
|
+
const query = options.silent ? '&probe=0&reuse=1' : '';
|
|
6105
|
+
const headers = {};
|
|
6106
|
+
if (state.mediaETag) headers['If-None-Match'] = state.mediaETag;
|
|
6107
|
+
const res = await fetch(`/api/resolve/media?limit=500${query}`, { headers, cache: 'no-store' });
|
|
6061
6108
|
state.mediaLastRefresh = new Date();
|
|
6109
|
+
state.resolveMediaStale = false;
|
|
6110
|
+
if (res.status === 304) return;
|
|
6111
|
+
const payload = await res.json();
|
|
6112
|
+
if (!res.ok || payload.success === false) {
|
|
6113
|
+
throw new Error(payload.error || res.statusText);
|
|
6114
|
+
}
|
|
6115
|
+
state.mediaETag = res.headers.get('ETag') || state.mediaETag;
|
|
6062
6116
|
renderResolveMedia(payload);
|
|
6117
|
+
saveInventorySnapshot(payload);
|
|
6063
6118
|
} finally {
|
|
6064
6119
|
state.mediaRefreshing = false;
|
|
6065
6120
|
updateMediaPollStatus();
|
|
6066
6121
|
}
|
|
6067
6122
|
}
|
|
6068
6123
|
|
|
6124
|
+
// Persist the last good inventory so reopening the dashboard paints the
|
|
6125
|
+
// previous snapshot instantly (with a "refreshing" hint) instead of sitting
|
|
6126
|
+
// on "connection pending" until the first fetch returns.
|
|
6127
|
+
function inventorySnapshotKey() {
|
|
6128
|
+
return 'resolveMcpInventory:' + (state.activeContext?.project_root || state.boot?.project_root || state.boot?.project_name || 'default');
|
|
6129
|
+
}
|
|
6130
|
+
function saveInventorySnapshot(payload) {
|
|
6131
|
+
if (!payload?.resolve_available) return;
|
|
6132
|
+
try {
|
|
6133
|
+
localStorage.setItem(inventorySnapshotKey(), JSON.stringify({ saved_at: Date.now(), payload }));
|
|
6134
|
+
} catch (error) {
|
|
6135
|
+
// Quota or serialization failure is non-fatal — the snapshot is a nicety.
|
|
6136
|
+
console.warn('Could not cache inventory snapshot', error);
|
|
6137
|
+
}
|
|
6138
|
+
}
|
|
6139
|
+
function loadInventorySnapshot() {
|
|
6140
|
+
try {
|
|
6141
|
+
const raw = localStorage.getItem(inventorySnapshotKey());
|
|
6142
|
+
if (!raw) return null;
|
|
6143
|
+
const parsed = JSON.parse(raw);
|
|
6144
|
+
return parsed?.payload?.resolve_available ? parsed.payload : null;
|
|
6145
|
+
} catch (error) {
|
|
6146
|
+
return null;
|
|
6147
|
+
}
|
|
6148
|
+
}
|
|
6149
|
+
|
|
6069
6150
|
function promptCandidateClips() {
|
|
6070
6151
|
return filteredResolveClips(state.resolveMedia?.clips || [])
|
|
6071
6152
|
.filter(clip => clip.source_clip && clip.analyzable && clip.clip_id);
|
|
@@ -6550,7 +6631,8 @@ HTML = r"""<!doctype html>
|
|
|
6550
6631
|
const poll = enabled ? `polling every ${Math.round(interval / 1000)}s` : 'polling off';
|
|
6551
6632
|
const visible = state.resolveMedia?.clips ? clipLabel(filteredResolveClips(state.resolveMedia.clips).length) : 'no media snapshot';
|
|
6552
6633
|
const prefix = state.mediaRefreshing ? 'refreshing' : poll;
|
|
6553
|
-
|
|
6634
|
+
const stale = state.resolveMediaStale ? 'cached · ' : '';
|
|
6635
|
+
el.textContent = `${stale}${prefix} · last ${last} · ${visible}${extra ? ` · ${extra}` : ''}`;
|
|
6554
6636
|
}
|
|
6555
6637
|
|
|
6556
6638
|
function rerenderResolveMedia() {
|
|
@@ -10504,27 +10586,74 @@ def _safe_id(obj: Any) -> Optional[str]:
|
|
|
10504
10586
|
return str(value) if value else None
|
|
10505
10587
|
|
|
10506
10588
|
|
|
10589
|
+
# ── Resolve scripting API serialization ─────────────────────────────────────
|
|
10590
|
+
# The dashboard runs on a ThreadingHTTPServer, so /api/boot, /api/projects and
|
|
10591
|
+
# /api/resolve/media can land on separate threads concurrently (especially at
|
|
10592
|
+
# startup). DaVinci's scripting API is not thread-safe, so every entry point that
|
|
10593
|
+
# talks to it acquires this re-entrant lock for the full duration of its calls.
|
|
10594
|
+
_RESOLVE_API_LOCK = threading.RLock()
|
|
10595
|
+
_RESOLVE_ENV_READY = False
|
|
10596
|
+
|
|
10597
|
+
|
|
10598
|
+
def _serialize_resolve(func):
|
|
10599
|
+
"""Decorator: hold the Resolve API lock for the whole call."""
|
|
10600
|
+
@functools.wraps(func)
|
|
10601
|
+
def wrapper(*args, **kwargs):
|
|
10602
|
+
with _RESOLVE_API_LOCK:
|
|
10603
|
+
return func(*args, **kwargs)
|
|
10604
|
+
return wrapper
|
|
10605
|
+
|
|
10606
|
+
|
|
10507
10607
|
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
|
-
|
|
10608
|
+
global _RESOLVE_ENV_READY
|
|
10609
|
+
with _RESOLVE_API_LOCK:
|
|
10610
|
+
# Environment + sys.path setup is pure overhead and never goes stale, so
|
|
10611
|
+
# run it once per process rather than on every connection.
|
|
10612
|
+
if not _RESOLVE_ENV_READY:
|
|
10613
|
+
try:
|
|
10614
|
+
setup_environment()
|
|
10615
|
+
modules_path = os.environ.get("RESOLVE_SCRIPT_API")
|
|
10616
|
+
if modules_path:
|
|
10617
|
+
candidate = os.path.join(modules_path, "Modules")
|
|
10618
|
+
if candidate not in sys.path:
|
|
10619
|
+
sys.path.append(candidate)
|
|
10620
|
+
_RESOLVE_ENV_READY = True
|
|
10621
|
+
except Exception as exc:
|
|
10622
|
+
return None, f"Resolve scripting API unavailable: {exc}"
|
|
10623
|
+
try:
|
|
10624
|
+
import DaVinciResolveScript as dvr_script # type: ignore
|
|
10625
|
+
except Exception as exc:
|
|
10626
|
+
return None, f"Resolve scripting API unavailable: {exc}"
|
|
10627
|
+
try:
|
|
10628
|
+
resolve = dvr_script.scriptapp("Resolve")
|
|
10629
|
+
except Exception as exc:
|
|
10630
|
+
return None, f"Resolve connection failed: {exc}"
|
|
10631
|
+
if resolve is None:
|
|
10632
|
+
return None, "DaVinci Resolve is not connected. Open Resolve Studio with a project loaded."
|
|
10633
|
+
return resolve, None
|
|
10634
|
+
|
|
10526
10635
|
|
|
10636
|
+
@_serialize_resolve
|
|
10637
|
+
def _current_resolve_project_id() -> Tuple[Optional[str], Optional[str]]:
|
|
10638
|
+
"""(project_id, error) for the currently-open Resolve project.
|
|
10527
10639
|
|
|
10640
|
+
A handful of cheap API calls — used by the media-poll reuse path to detect
|
|
10641
|
+
when the user has switched projects in Resolve since the inventory was cached,
|
|
10642
|
+
without paying for a full Media Pool walk.
|
|
10643
|
+
"""
|
|
10644
|
+
resolve, error = _connect_resolve_read_only()
|
|
10645
|
+
if error or resolve is None:
|
|
10646
|
+
return None, error or "Resolve unavailable"
|
|
10647
|
+
pm, pm_error = _safe_call(resolve, "GetProjectManager")
|
|
10648
|
+
if not pm or pm_error:
|
|
10649
|
+
return None, pm_error or "Project manager unavailable"
|
|
10650
|
+
project, _ = _safe_call(pm, "GetCurrentProject")
|
|
10651
|
+
if not project:
|
|
10652
|
+
return None, "No Resolve project open"
|
|
10653
|
+
return _safe_id(project), None
|
|
10654
|
+
|
|
10655
|
+
|
|
10656
|
+
@_serialize_resolve
|
|
10528
10657
|
def _resolve_identity() -> Dict[str, Any]:
|
|
10529
10658
|
resolve, error = _connect_resolve_read_only()
|
|
10530
10659
|
if not resolve:
|
|
@@ -10555,7 +10684,64 @@ def _first_prop(props: Dict[str, Any], keys: Tuple[str, ...]) -> Any:
|
|
|
10555
10684
|
return None
|
|
10556
10685
|
|
|
10557
10686
|
|
|
10558
|
-
|
|
10687
|
+
# ── File-existence probing ──────────────────────────────────────────────────
|
|
10688
|
+
# stat() calls on mounted network storage dominate inventory time (300+ source
|
|
10689
|
+
# clips on a Z:\ share can take tens of seconds serially). We probe paths in a
|
|
10690
|
+
# thread pool and memoize results for a short TTL so the recurring media poll
|
|
10691
|
+
# does not re-stat unchanged paths every few seconds.
|
|
10692
|
+
_PATH_EXISTS_TTL = 60.0
|
|
10693
|
+
_PATH_PROBE_WORKERS = 16
|
|
10694
|
+
_PATH_EXISTS_CACHE: Dict[str, Tuple[float, bool]] = {}
|
|
10695
|
+
_PATH_EXISTS_LOCK = threading.Lock()
|
|
10696
|
+
|
|
10697
|
+
|
|
10698
|
+
def _cached_path_exists(path: str, now: float, ttl: float) -> Optional[bool]:
|
|
10699
|
+
with _PATH_EXISTS_LOCK:
|
|
10700
|
+
entry = _PATH_EXISTS_CACHE.get(path)
|
|
10701
|
+
if entry is not None and (now - entry[0]) <= ttl:
|
|
10702
|
+
return entry[1]
|
|
10703
|
+
return None
|
|
10704
|
+
|
|
10705
|
+
|
|
10706
|
+
def _store_path_exists(path: str, exists: bool, now: float) -> None:
|
|
10707
|
+
with _PATH_EXISTS_LOCK:
|
|
10708
|
+
_PATH_EXISTS_CACHE[path] = (now, exists)
|
|
10709
|
+
|
|
10710
|
+
|
|
10711
|
+
def _probe_paths_exist(paths: Any, *, probe: bool = True, ttl: float = _PATH_EXISTS_TTL) -> Dict[str, bool]:
|
|
10712
|
+
"""Resolve a collection of file paths to existence booleans.
|
|
10713
|
+
|
|
10714
|
+
With ``probe=True`` (first load / manual refresh) any cache entry older than
|
|
10715
|
+
``ttl`` is re-stat'd, and uncached paths are probed in parallel. With
|
|
10716
|
+
``probe=False`` (background poll) the filesystem is never touched: cached
|
|
10717
|
+
values are reused at any age and unknown paths fall back to ``True`` —
|
|
10718
|
+
Resolve's own online/offline Status property still flags clips it knows are
|
|
10719
|
+
missing, so we trust it rather than paying for a network round-trip on every
|
|
10720
|
+
poll.
|
|
10721
|
+
"""
|
|
10722
|
+
distinct = {str(p) for p in paths if p}
|
|
10723
|
+
result: Dict[str, bool] = {}
|
|
10724
|
+
to_probe: List[str] = []
|
|
10725
|
+
now = time.time()
|
|
10726
|
+
lookup_ttl = ttl if probe else float("inf")
|
|
10727
|
+
for path in distinct:
|
|
10728
|
+
cached = _cached_path_exists(path, now, lookup_ttl)
|
|
10729
|
+
if cached is not None:
|
|
10730
|
+
result[path] = cached
|
|
10731
|
+
elif probe:
|
|
10732
|
+
to_probe.append(path)
|
|
10733
|
+
else:
|
|
10734
|
+
result[path] = True
|
|
10735
|
+
if to_probe:
|
|
10736
|
+
workers = max(1, min(_PATH_PROBE_WORKERS, len(to_probe)))
|
|
10737
|
+
with ThreadPoolExecutor(max_workers=workers) as pool:
|
|
10738
|
+
for path, exists in zip(to_probe, pool.map(os.path.exists, to_probe)):
|
|
10739
|
+
result[path] = bool(exists)
|
|
10740
|
+
_store_path_exists(path, bool(exists), now)
|
|
10741
|
+
return result
|
|
10742
|
+
|
|
10743
|
+
|
|
10744
|
+
def _media_status(props: Dict[str, Any], file_path: Optional[str], *, file_exists: Optional[bool] = None) -> str:
|
|
10559
10745
|
status_text = str(_first_prop(props, ("Status", "Media Status", "Online Status", "Offline")) or "").strip().lower()
|
|
10560
10746
|
if not file_path:
|
|
10561
10747
|
return "no_path"
|
|
@@ -10563,7 +10749,11 @@ def _media_status(props: Dict[str, Any], file_path: Optional[str]) -> str:
|
|
|
10563
10749
|
return "offline"
|
|
10564
10750
|
if "missing" in status_text:
|
|
10565
10751
|
return "missing_file"
|
|
10566
|
-
|
|
10752
|
+
# Pass `file_exists` in to reuse a single os.path.exists() probe — stat calls on
|
|
10753
|
+
# network source media are slow, so the caller avoids probing the same path twice.
|
|
10754
|
+
if file_exists is None:
|
|
10755
|
+
file_exists = os.path.exists(str(file_path))
|
|
10756
|
+
if not file_exists:
|
|
10567
10757
|
return "missing_file"
|
|
10568
10758
|
return "online"
|
|
10569
10759
|
|
|
@@ -10644,9 +10834,18 @@ def _resolve_clip_record(clip: Any, bin_path: str, selected_ids: set) -> Dict[st
|
|
|
10644
10834
|
"resolve_status": _first_prop(props, ("Status", "Media Status", "Online Status", "Offline")),
|
|
10645
10835
|
"selected": bool(clip_id and clip_id in selected_ids),
|
|
10646
10836
|
}
|
|
10647
|
-
|
|
10648
|
-
|
|
10837
|
+
# File-existence is resolved in a single parallel batch after every clip's
|
|
10838
|
+
# Resolve properties are gathered (see resolve_media_inventory), so we stash
|
|
10839
|
+
# the props and defer existence-dependent fields to _finalize_clip_record.
|
|
10649
10840
|
record["clip_key"] = stable_clip_directory(record)
|
|
10841
|
+
record["_props"] = props
|
|
10842
|
+
return record
|
|
10843
|
+
|
|
10844
|
+
|
|
10845
|
+
def _finalize_clip_record(record: Dict[str, Any], file_exists: bool) -> Dict[str, Any]:
|
|
10846
|
+
props = record.pop("_props", {}) or {}
|
|
10847
|
+
record["file_exists"] = file_exists
|
|
10848
|
+
record["status"] = _media_status(props, record["file_path"], file_exists=file_exists)
|
|
10650
10849
|
record["source_clip"], record["source_clip_reason"] = _source_clip_status(record, props)
|
|
10651
10850
|
record["analyzable"], record["analyzable_reason"] = _analyzable_clip_status(record, props)
|
|
10652
10851
|
return record
|
|
@@ -10716,11 +10915,32 @@ def _analysis_status_by_clip(project_root: str, records: List[Dict[str, Any]]) -
|
|
|
10716
10915
|
status_by_key: Dict[str, Dict[str, Any]] = {}
|
|
10717
10916
|
if not keys:
|
|
10718
10917
|
return status_by_key
|
|
10918
|
+
|
|
10919
|
+
# Resolve each clip to its report via the persisted clip index, which maps
|
|
10920
|
+
# every stable id found in a report (normalized + raw file path, clip_id,
|
|
10921
|
+
# media_id) to its folder. This survives a Media Pool rename, a legacy hash
|
|
10922
|
+
# basis, AND an offline clip that no longer reports a file path but still
|
|
10923
|
+
# carries its clip_id — none of which a folder-name scan can match. See #51.
|
|
10924
|
+
clips_root = os.path.join(project_root, "clips")
|
|
10925
|
+
hash_to_folder = load_clip_index(project_root).get("hash_to_folder") or {}
|
|
10926
|
+
|
|
10719
10927
|
for record in records:
|
|
10720
10928
|
clip_key = record.get("clip_key")
|
|
10721
10929
|
if not clip_key:
|
|
10722
10930
|
continue
|
|
10723
10931
|
report_path = os.path.join(project_root, "clips", str(clip_key), "analysis.json")
|
|
10932
|
+
if not os.path.isfile(report_path):
|
|
10933
|
+
# Renamed/legacy/offline clip: the recomputed clip_key no longer
|
|
10934
|
+
# matches the folder on disk. Fall back to any of the clip's stable
|
|
10935
|
+
# hashes via the index.
|
|
10936
|
+
for clip_hash in stable_clip_match_hashes(record):
|
|
10937
|
+
folder = hash_to_folder.get(clip_hash)
|
|
10938
|
+
if not folder:
|
|
10939
|
+
continue
|
|
10940
|
+
candidate = os.path.join(clips_root, folder, "analysis.json")
|
|
10941
|
+
if os.path.isfile(candidate):
|
|
10942
|
+
report_path = candidate
|
|
10943
|
+
break
|
|
10724
10944
|
if os.path.isfile(report_path):
|
|
10725
10945
|
status_by_key[str(clip_key)] = {
|
|
10726
10946
|
"analysis_status": "analyzed",
|
|
@@ -10730,32 +10950,24 @@ def _analysis_status_by_clip(project_root: str, records: List[Dict[str, Any]]) -
|
|
|
10730
10950
|
db_path = os.path.join(project_root, "jobs.sqlite")
|
|
10731
10951
|
if not os.path.isfile(db_path):
|
|
10732
10952
|
return status_by_key
|
|
10733
|
-
|
|
10734
|
-
|
|
10735
|
-
|
|
10736
|
-
|
|
10737
|
-
|
|
10738
|
-
|
|
10739
|
-
|
|
10740
|
-
|
|
10741
|
-
|
|
10742
|
-
|
|
10743
|
-
|
|
10744
|
-
ORDER BY jc.updated_at DESC
|
|
10745
|
-
""",
|
|
10746
|
-
keys,
|
|
10747
|
-
).fetchall()
|
|
10748
|
-
except Exception:
|
|
10749
|
-
return status_by_key
|
|
10750
|
-
finally:
|
|
10751
|
-
try:
|
|
10752
|
-
conn.close()
|
|
10753
|
-
except Exception:
|
|
10754
|
-
pass
|
|
10755
|
-
for row in rows:
|
|
10756
|
-
clip_key = str(row["clip_key"])
|
|
10757
|
-
if clip_key in status_by_key and status_by_key[clip_key].get("analysis_status") == "analyzed":
|
|
10953
|
+
|
|
10954
|
+
# The jobs DB stores each clip under the clip_key it had when analyzed. A
|
|
10955
|
+
# clip renamed afterwards produces a new clip_key, so an exact-key match
|
|
10956
|
+
# misses its job row. Index unresolved records by their rename-stable hash
|
|
10957
|
+
# so a DB row recorded under the old name (e.g. a reused batch report living
|
|
10958
|
+
# outside the local clips/ dir) still maps back to the current clip. #51.
|
|
10959
|
+
key_set = {str(k) for k in keys}
|
|
10960
|
+
pending_hash_to_key: Dict[str, str] = {}
|
|
10961
|
+
for record in records:
|
|
10962
|
+
clip_key = record.get("clip_key")
|
|
10963
|
+
if not clip_key or str(clip_key) in status_by_key:
|
|
10758
10964
|
continue
|
|
10965
|
+
for folder_hash in stable_clip_match_hashes(record):
|
|
10966
|
+
pending_hash_to_key.setdefault(folder_hash, str(clip_key))
|
|
10967
|
+
|
|
10968
|
+
def _apply_row(row: sqlite3.Row, target_key: str) -> None:
|
|
10969
|
+
if status_by_key.get(target_key, {}).get("analysis_status") == "analyzed":
|
|
10970
|
+
return
|
|
10759
10971
|
db_status = row["status"]
|
|
10760
10972
|
report_path = row["report_path"]
|
|
10761
10973
|
# In media_analysis_jobs, 'succeeded' = fresh analysis written this run,
|
|
@@ -10773,7 +10985,7 @@ def _analysis_status_by_clip(project_root: str, records: List[Dict[str, Any]]) -
|
|
|
10773
10985
|
normalized = db_status
|
|
10774
10986
|
if db_status in ("succeeded", "skipped") and report_resolves:
|
|
10775
10987
|
normalized = "analyzed"
|
|
10776
|
-
status_by_key[
|
|
10988
|
+
status_by_key[target_key] = {
|
|
10777
10989
|
"analysis_status": normalized,
|
|
10778
10990
|
"job_status": db_status,
|
|
10779
10991
|
"cache_status": row["cache_status"],
|
|
@@ -10783,80 +10995,78 @@ def _analysis_status_by_clip(project_root: str, records: List[Dict[str, Any]]) -
|
|
|
10783
10995
|
"job_name": row["job_name"],
|
|
10784
10996
|
"job_updated_at": row["updated_at"],
|
|
10785
10997
|
}
|
|
10786
|
-
return status_by_key
|
|
10787
|
-
|
|
10788
10998
|
|
|
10789
|
-
|
|
10999
|
+
select_cols = (
|
|
11000
|
+
"SELECT jc.clip_key, jc.status, jc.cache_status, jc.report_path, jc.error, "
|
|
11001
|
+
"j.job_id, j.name AS job_name, j.updated_at "
|
|
11002
|
+
"FROM job_clips jc JOIN jobs j ON j.job_id = jc.job_id"
|
|
11003
|
+
)
|
|
11004
|
+
placeholders = ",".join("?" for _ in keys)
|
|
10790
11005
|
try:
|
|
10791
|
-
|
|
10792
|
-
|
|
10793
|
-
|
|
10794
|
-
|
|
10795
|
-
|
|
10796
|
-
|
|
10797
|
-
|
|
10798
|
-
"
|
|
10799
|
-
|
|
10800
|
-
|
|
10801
|
-
|
|
10802
|
-
|
|
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(),
|
|
11006
|
+
conn = sqlite3.connect(db_path)
|
|
11007
|
+
conn.row_factory = sqlite3.Row
|
|
11008
|
+
rows = conn.execute(
|
|
11009
|
+
f"{select_cols} WHERE jc.clip_key IN ({placeholders}) ORDER BY jc.updated_at DESC",
|
|
11010
|
+
keys,
|
|
11011
|
+
).fetchall()
|
|
11012
|
+
for row in rows:
|
|
11013
|
+
_apply_row(row, str(row["clip_key"]))
|
|
11014
|
+
# Only pay for the unfiltered scan when a rename actually left a record
|
|
11015
|
+
# unresolved by the disk pass and exact-key match above.
|
|
11016
|
+
unresolved = {
|
|
11017
|
+
h: k for h, k in pending_hash_to_key.items() if k not in status_by_key
|
|
10836
11018
|
}
|
|
11019
|
+
if unresolved:
|
|
11020
|
+
for row in conn.execute(
|
|
11021
|
+
f"{select_cols} ORDER BY jc.updated_at DESC"
|
|
11022
|
+
).fetchall():
|
|
11023
|
+
raw_key = str(row["clip_key"])
|
|
11024
|
+
if raw_key in key_set:
|
|
11025
|
+
continue
|
|
11026
|
+
row_hash = clip_directory_hash(raw_key)
|
|
11027
|
+
target_key = unresolved.get(row_hash) if row_hash else None
|
|
11028
|
+
if target_key:
|
|
11029
|
+
_apply_row(row, target_key)
|
|
11030
|
+
except Exception:
|
|
11031
|
+
return status_by_key
|
|
11032
|
+
finally:
|
|
11033
|
+
try:
|
|
11034
|
+
conn.close()
|
|
11035
|
+
except Exception:
|
|
11036
|
+
pass
|
|
11037
|
+
return status_by_key
|
|
10837
11038
|
|
|
10838
|
-
|
|
10839
|
-
|
|
10840
|
-
|
|
10841
|
-
|
|
10842
|
-
|
|
10843
|
-
|
|
10844
|
-
|
|
10845
|
-
|
|
10846
|
-
|
|
10847
|
-
|
|
10848
|
-
|
|
10849
|
-
|
|
10850
|
-
|
|
10851
|
-
|
|
10852
|
-
|
|
10853
|
-
|
|
10854
|
-
|
|
10855
|
-
|
|
11039
|
+
|
|
11040
|
+
# Last full Resolve walk per project_root, kept overlay-free so the analysis
|
|
11041
|
+
# status can be re-applied cheaply on every background poll without re-walking
|
|
11042
|
+
# the Media Pool (the expensive, non-parallelizable GetClipProperty pass).
|
|
11043
|
+
_INVENTORY_CACHE: Dict[str, Dict[str, Any]] = {}
|
|
11044
|
+
_INVENTORY_LOCK = threading.Lock()
|
|
11045
|
+
|
|
11046
|
+
|
|
11047
|
+
def _get_cached_inventory(project_root: str) -> Optional[Dict[str, Any]]:
|
|
11048
|
+
with _INVENTORY_LOCK:
|
|
11049
|
+
return _INVENTORY_CACHE.get(project_root)
|
|
11050
|
+
|
|
11051
|
+
|
|
11052
|
+
def _store_cached_inventory(project_root: str, entry: Dict[str, Any]) -> None:
|
|
11053
|
+
with _INVENTORY_LOCK:
|
|
11054
|
+
_INVENTORY_CACHE[project_root] = entry
|
|
11055
|
+
|
|
11056
|
+
|
|
11057
|
+
def _assemble_inventory_payload(project_root: str, entry: Dict[str, Any]) -> Dict[str, Any]:
|
|
11058
|
+
"""Apply the (local, cheap) analysis-status overlay onto cached base records.
|
|
11059
|
+
|
|
11060
|
+
Base records hold the Resolve-derived fields plus file existence; the analysis
|
|
11061
|
+
overlay (queued/running/analyzed, report paths, job ids) is re-read from disk
|
|
11062
|
+
every call so a background poll reflects job progress without touching Resolve.
|
|
11063
|
+
Records are copied so the cached base stays overlay-free across polls.
|
|
11064
|
+
"""
|
|
11065
|
+
records = [dict(record) for record in entry["base_records"]]
|
|
10856
11066
|
status_by_key = _analysis_status_by_clip(project_root, records)
|
|
10857
11067
|
counts = _empty_media_counts()
|
|
10858
11068
|
counts["total"] = len(records)
|
|
10859
|
-
counts["selected"] =
|
|
11069
|
+
counts["selected"] = entry.get("selected_count", sum(1 for r in records if r.get("selected")))
|
|
10860
11070
|
for record in records:
|
|
10861
11071
|
status = record.get("status") or "unknown"
|
|
10862
11072
|
if status in counts:
|
|
@@ -10879,17 +11089,140 @@ def resolve_media_inventory(project_root: str, *, limit: Any = 500, recursive: b
|
|
|
10879
11089
|
"success": True,
|
|
10880
11090
|
"resolve_available": True,
|
|
10881
11091
|
"status": "Resolve connected",
|
|
10882
|
-
"project":
|
|
10883
|
-
"name": _safe_name(project, "Resolve Project"),
|
|
10884
|
-
"id": _safe_id(project),
|
|
10885
|
-
},
|
|
11092
|
+
"project": entry["project"],
|
|
10886
11093
|
"project_root": project_root,
|
|
10887
11094
|
"clips": records,
|
|
10888
11095
|
"counts": counts,
|
|
11096
|
+
"truncated": bool(entry.get("truncated")),
|
|
11097
|
+
"limit": entry.get("limit"),
|
|
11098
|
+
"warnings": entry.get("warnings", []),
|
|
11099
|
+
}
|
|
11100
|
+
|
|
11101
|
+
|
|
11102
|
+
def resolve_media_inventory(
|
|
11103
|
+
project_root: str,
|
|
11104
|
+
*,
|
|
11105
|
+
limit: Any = 500,
|
|
11106
|
+
recursive: bool = True,
|
|
11107
|
+
probe_paths: bool = True,
|
|
11108
|
+
reuse_cached: bool = False,
|
|
11109
|
+
) -> Dict[str, Any]:
|
|
11110
|
+
try:
|
|
11111
|
+
max_items = max(1, min(int(limit), 2000))
|
|
11112
|
+
except (TypeError, ValueError):
|
|
11113
|
+
max_items = 500
|
|
11114
|
+
|
|
11115
|
+
# Background polls only need to surface analysis progress (a local, disk-backed
|
|
11116
|
+
# signal), so they reuse the last Resolve walk instead of paying for ~N serial
|
|
11117
|
+
# GetClipProperty round-trips again. A cheap project-id check still catches a
|
|
11118
|
+
# project switch made directly in Resolve (a handful of API calls vs a full
|
|
11119
|
+
# walk); we rebuild only on a confirmed mismatch. If the current project can't
|
|
11120
|
+
# be determined (Resolve down / no project open), we keep serving the cache —
|
|
11121
|
+
# a transient blip shouldn't trigger an expensive rebuild on every poll.
|
|
11122
|
+
if reuse_cached:
|
|
11123
|
+
cached = _get_cached_inventory(project_root)
|
|
11124
|
+
if cached is not None:
|
|
11125
|
+
current_id, id_error = _current_resolve_project_id()
|
|
11126
|
+
cached_id = (cached.get("project") or {}).get("id")
|
|
11127
|
+
project_changed = (
|
|
11128
|
+
id_error is None
|
|
11129
|
+
and current_id is not None
|
|
11130
|
+
and str(current_id) != str(cached_id)
|
|
11131
|
+
)
|
|
11132
|
+
if not project_changed:
|
|
11133
|
+
return _assemble_inventory_payload(project_root, cached)
|
|
11134
|
+
|
|
11135
|
+
# Everything that touches the Resolve scripting API stays under the lock; the
|
|
11136
|
+
# parallel path probe and the disk overlay run outside it.
|
|
11137
|
+
with _RESOLVE_API_LOCK:
|
|
11138
|
+
resolve, resolve_error = _connect_resolve_read_only()
|
|
11139
|
+
if resolve_error:
|
|
11140
|
+
return {
|
|
11141
|
+
"success": True,
|
|
11142
|
+
"resolve_available": False,
|
|
11143
|
+
"status": "Resolve unavailable",
|
|
11144
|
+
"error": resolve_error,
|
|
11145
|
+
"clips": [],
|
|
11146
|
+
"counts": _empty_media_counts(),
|
|
11147
|
+
}
|
|
11148
|
+
pm, pm_error = _safe_call(resolve, "GetProjectManager")
|
|
11149
|
+
project = None
|
|
11150
|
+
if pm and not pm_error:
|
|
11151
|
+
project, _ = _safe_call(pm, "GetCurrentProject")
|
|
11152
|
+
if not project:
|
|
11153
|
+
return {
|
|
11154
|
+
"success": True,
|
|
11155
|
+
"resolve_available": False,
|
|
11156
|
+
"status": "No Resolve project",
|
|
11157
|
+
"error": "DaVinci Resolve is connected, but no project is open.",
|
|
11158
|
+
"clips": [],
|
|
11159
|
+
"counts": _empty_media_counts(),
|
|
11160
|
+
}
|
|
11161
|
+
media_pool, mp_error = _safe_call(project, "GetMediaPool")
|
|
11162
|
+
if not media_pool or mp_error:
|
|
11163
|
+
return {
|
|
11164
|
+
"success": True,
|
|
11165
|
+
"resolve_available": False,
|
|
11166
|
+
"status": "Media Pool unavailable",
|
|
11167
|
+
"error": mp_error or "Failed to get Resolve Media Pool",
|
|
11168
|
+
"clips": [],
|
|
11169
|
+
"counts": _empty_media_counts(),
|
|
11170
|
+
}
|
|
11171
|
+
root_folder, root_error = _safe_call(media_pool, "GetRootFolder")
|
|
11172
|
+
if not root_folder or root_error:
|
|
11173
|
+
return {
|
|
11174
|
+
"success": True,
|
|
11175
|
+
"resolve_available": False,
|
|
11176
|
+
"status": "Root folder unavailable",
|
|
11177
|
+
"error": root_error or "Failed to get Resolve root folder",
|
|
11178
|
+
"clips": [],
|
|
11179
|
+
"counts": _empty_media_counts(),
|
|
11180
|
+
}
|
|
11181
|
+
|
|
11182
|
+
selected_ids = set()
|
|
11183
|
+
selected_clips, _ = _safe_call(media_pool, "GetSelectedClips")
|
|
11184
|
+
for clip in selected_clips or []:
|
|
11185
|
+
clip_id = _safe_id(clip)
|
|
11186
|
+
if clip_id:
|
|
11187
|
+
selected_ids.add(clip_id)
|
|
11188
|
+
|
|
11189
|
+
warnings: List[str] = []
|
|
11190
|
+
records: List[Dict[str, Any]] = []
|
|
11191
|
+
truncated = _append_folder_media(
|
|
11192
|
+
root_folder,
|
|
11193
|
+
bin_path="Master",
|
|
11194
|
+
recursive=recursive,
|
|
11195
|
+
selected_ids=selected_ids,
|
|
11196
|
+
records=records,
|
|
11197
|
+
warnings=warnings,
|
|
11198
|
+
limit=max_items,
|
|
11199
|
+
)
|
|
11200
|
+
project_info = {
|
|
11201
|
+
"name": _safe_name(project, "Resolve Project"),
|
|
11202
|
+
"id": _safe_id(project),
|
|
11203
|
+
}
|
|
11204
|
+
selected_count = len(selected_ids)
|
|
11205
|
+
|
|
11206
|
+
# Resolve every clip's file path in one parallel, cache-backed batch, then
|
|
11207
|
+
# finalize existence-dependent fields (status / analyzable).
|
|
11208
|
+
existence = _probe_paths_exist(
|
|
11209
|
+
(record.get("file_path") for record in records),
|
|
11210
|
+
probe=probe_paths,
|
|
11211
|
+
)
|
|
11212
|
+
for record in records:
|
|
11213
|
+
file_path = record.get("file_path")
|
|
11214
|
+
_finalize_clip_record(record, bool(file_path) and existence.get(str(file_path), False))
|
|
11215
|
+
|
|
11216
|
+
entry = {
|
|
11217
|
+
"base_records": records,
|
|
11218
|
+
"project": project_info,
|
|
11219
|
+
"selected_count": selected_count,
|
|
10889
11220
|
"truncated": bool(truncated),
|
|
10890
11221
|
"limit": max_items,
|
|
10891
11222
|
"warnings": warnings,
|
|
10892
11223
|
}
|
|
11224
|
+
_store_cached_inventory(project_root, entry)
|
|
11225
|
+
return _assemble_inventory_payload(project_root, entry)
|
|
10893
11226
|
|
|
10894
11227
|
|
|
10895
11228
|
_PROJECT_CONTEXT_RE = re.compile(r"^(?P<slug>.+)-(?P<hash>[0-9a-f]{10})$")
|
|
@@ -10941,6 +11274,7 @@ def _context_from_project_root(base_root: str, project_root: str, *, source: str
|
|
|
10941
11274
|
}
|
|
10942
11275
|
|
|
10943
11276
|
|
|
11277
|
+
@_serialize_resolve
|
|
10944
11278
|
def _current_resolve_project_context(base_root: str) -> Optional[Dict[str, Any]]:
|
|
10945
11279
|
resolve, resolve_error = _connect_resolve_read_only()
|
|
10946
11280
|
if resolve_error:
|
|
@@ -11001,6 +11335,7 @@ def _project_folder_label(folder_path: List[str]) -> str:
|
|
|
11001
11335
|
return " / ".join(folder_path) if folder_path else "Root"
|
|
11002
11336
|
|
|
11003
11337
|
|
|
11338
|
+
@_serialize_resolve
|
|
11004
11339
|
def _resolve_all_project_contexts(base_root: str, *, max_depth: int = 12, max_projects: int = 2000) -> Dict[str, Any]:
|
|
11005
11340
|
resolve, resolve_error = _connect_resolve_read_only()
|
|
11006
11341
|
if resolve_error:
|
|
@@ -11123,6 +11458,7 @@ def _resolve_all_project_contexts(base_root: str, *, max_depth: int = 12, max_pr
|
|
|
11123
11458
|
}
|
|
11124
11459
|
|
|
11125
11460
|
|
|
11461
|
+
@_serialize_resolve
|
|
11126
11462
|
def _resolve_project_contexts(base_root: str) -> Dict[str, Any]:
|
|
11127
11463
|
resolve, resolve_error = _connect_resolve_read_only()
|
|
11128
11464
|
if resolve_error:
|
|
@@ -11196,6 +11532,7 @@ def _resolve_project_contexts(base_root: str) -> Dict[str, Any]:
|
|
|
11196
11532
|
}
|
|
11197
11533
|
|
|
11198
11534
|
|
|
11535
|
+
@_serialize_resolve
|
|
11199
11536
|
def _load_resolve_project_context(base_root: str, project_name: Any, folder_path: Any = None) -> Dict[str, Any]:
|
|
11200
11537
|
target_name = str(project_name or "").strip()
|
|
11201
11538
|
if not target_name:
|
|
@@ -12960,6 +13297,27 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
12960
13297
|
self.end_headers()
|
|
12961
13298
|
self.wfile.write(raw)
|
|
12962
13299
|
|
|
13300
|
+
def _json_etag(self, payload: Dict[str, Any]) -> None:
|
|
13301
|
+
"""JSON response with an ETag so unchanged polls short-circuit to 304.
|
|
13302
|
+
|
|
13303
|
+
The Resolve media inventory is re-fetched every few seconds; when the
|
|
13304
|
+
serialized payload is byte-identical to what the client already holds we
|
|
13305
|
+
skip both the body transfer and the client-side re-render of the table.
|
|
13306
|
+
"""
|
|
13307
|
+
raw = json.dumps(payload, ensure_ascii=False, default=str).encode("utf-8")
|
|
13308
|
+
etag = '"' + hashlib.md5(raw).hexdigest() + '"'
|
|
13309
|
+
if self.headers.get("If-None-Match") == etag:
|
|
13310
|
+
self.send_response(HTTPStatus.NOT_MODIFIED)
|
|
13311
|
+
self.send_header("ETag", etag)
|
|
13312
|
+
self.end_headers()
|
|
13313
|
+
return
|
|
13314
|
+
self.send_response(200)
|
|
13315
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
13316
|
+
self.send_header("Content-Length", str(len(raw)))
|
|
13317
|
+
self.send_header("ETag", etag)
|
|
13318
|
+
self.end_headers()
|
|
13319
|
+
self.wfile.write(raw)
|
|
13320
|
+
|
|
12963
13321
|
def _serve_file(self, path: str, content_type: str = "application/octet-stream") -> None:
|
|
12964
13322
|
try:
|
|
12965
13323
|
with open(path, "rb") as handle:
|
|
@@ -13089,11 +13447,13 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
13089
13447
|
self._json(_setup_defaults("get_defaults"))
|
|
13090
13448
|
return
|
|
13091
13449
|
if path == "/api/resolve/media":
|
|
13092
|
-
self.
|
|
13450
|
+
self._json_etag(
|
|
13093
13451
|
resolve_media_inventory(
|
|
13094
13452
|
self.state.project_root,
|
|
13095
13453
|
limit=(query.get("limit") or [500])[0],
|
|
13096
13454
|
recursive=(query.get("recursive") or ["true"])[0].lower() not in {"0", "false", "no"},
|
|
13455
|
+
probe_paths=(query.get("probe") or ["1"])[0].lower() not in {"0", "false", "no"},
|
|
13456
|
+
reuse_cached=(query.get("reuse") or ["0"])[0].lower() in {"1", "true", "yes"},
|
|
13097
13457
|
)
|
|
13098
13458
|
)
|
|
13099
13459
|
return
|
|
@@ -13556,6 +13916,20 @@ def parse_args() -> argparse.Namespace:
|
|
|
13556
13916
|
return parser.parse_args()
|
|
13557
13917
|
|
|
13558
13918
|
|
|
13919
|
+
def _warm_inventory_cache(project_root: str) -> None:
|
|
13920
|
+
"""Build the first Resolve inventory in the background at startup.
|
|
13921
|
+
|
|
13922
|
+
Populates the inventory + path-existence caches before the browser connects so
|
|
13923
|
+
the first dashboard open paints live data immediately instead of waiting on a
|
|
13924
|
+
cold Media Pool walk. Best-effort: if Resolve isn't up yet this no-ops and the
|
|
13925
|
+
first real request builds normally.
|
|
13926
|
+
"""
|
|
13927
|
+
try:
|
|
13928
|
+
resolve_media_inventory(project_root)
|
|
13929
|
+
except Exception: # noqa: BLE001 — warm-up must never crash startup
|
|
13930
|
+
pass
|
|
13931
|
+
|
|
13932
|
+
|
|
13559
13933
|
def main() -> None:
|
|
13560
13934
|
args = parse_args()
|
|
13561
13935
|
state = DashboardState(args.project_name, args.project_id, args.analysis_root)
|
|
@@ -13564,6 +13938,7 @@ def main() -> None:
|
|
|
13564
13938
|
url = f"http://{args.host}:{args.port}"
|
|
13565
13939
|
print(f"DaVinci Resolve MCP: {url}")
|
|
13566
13940
|
print(f"Project analysis root: {state.project_root}")
|
|
13941
|
+
threading.Thread(target=_warm_inventory_cache, args=(state.project_root,), daemon=True).start()
|
|
13567
13942
|
if args.open:
|
|
13568
13943
|
webbrowser.open(url)
|
|
13569
13944
|
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.2"
|
|
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
|
@@ -739,16 +739,225 @@ def project_directory_name(project_name: Any, project_id: Any = None) -> str:
|
|
|
739
739
|
return f"{slugify(project_name, 'project')}-{short_hash(basis)}"
|
|
740
740
|
|
|
741
741
|
|
|
742
|
-
def
|
|
743
|
-
|
|
742
|
+
def stable_clip_basis(record: Dict[str, Any]) -> str:
|
|
743
|
+
"""Return the canonical rename-stable identity used to hash a report folder.
|
|
744
|
+
|
|
745
|
+
The canonical basis is the *normalized file path*: it is present on both
|
|
746
|
+
Resolve-derived and path-based batch records, it survives a Media Pool
|
|
747
|
+
rename, and a genuine relink to a different file is handled separately as a
|
|
748
|
+
superseded source. Resolve-internal ids (clip_id/media_id) are absent from
|
|
749
|
+
path-based records and not portable across project copies, so they are only
|
|
750
|
+
used when no file path is available; the display name is the last resort.
|
|
751
|
+
|
|
752
|
+
Folder *resolution* (matching an existing report) must tolerate the legacy
|
|
753
|
+
bases too — see :func:`stable_clip_match_hashes`.
|
|
754
|
+
"""
|
|
755
|
+
file_path = record.get("file_path")
|
|
756
|
+
if file_path:
|
|
757
|
+
return normalize_path(file_path)
|
|
758
|
+
return str(
|
|
744
759
|
record.get("clip_id")
|
|
745
760
|
or record.get("media_id")
|
|
746
|
-
or record.get("file_path")
|
|
747
761
|
or record.get("clip_name")
|
|
748
762
|
or "clip"
|
|
749
763
|
)
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
def stable_clip_hash(record: Dict[str, Any]) -> str:
|
|
767
|
+
"""Return the canonical 12-char hash that anchors a clip's report folder."""
|
|
768
|
+
return short_hash(stable_clip_basis(record), 12)
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
def stable_clip_match_hashes(record: Dict[str, Any]) -> List[str]:
|
|
772
|
+
"""All folder hashes that could identify this clip's existing report.
|
|
773
|
+
|
|
774
|
+
Returns the canonical hash first, followed by legacy bases so reports
|
|
775
|
+
written before the canonical file-path scheme (clip_id-first, or a raw
|
|
776
|
+
un-normalized path) still resolve without an on-disk migration. The display
|
|
777
|
+
name is only used when nothing more unique is available, so two different
|
|
778
|
+
clips that merely share a name are never matched to the same report.
|
|
779
|
+
"""
|
|
780
|
+
hashes: List[str] = []
|
|
781
|
+
seen: set = set()
|
|
782
|
+
|
|
783
|
+
def add(value: Any) -> None:
|
|
784
|
+
if not value:
|
|
785
|
+
return
|
|
786
|
+
digest = short_hash(value, 12)
|
|
787
|
+
if digest not in seen:
|
|
788
|
+
seen.add(digest)
|
|
789
|
+
hashes.append(digest)
|
|
790
|
+
|
|
791
|
+
file_path = record.get("file_path")
|
|
792
|
+
if file_path:
|
|
793
|
+
add(normalize_path(file_path)) # canonical
|
|
794
|
+
add(str(file_path)) # legacy: raw, un-normalized path
|
|
795
|
+
add(record.get("clip_id")) # legacy: clip_id-first scheme
|
|
796
|
+
add(record.get("media_id"))
|
|
797
|
+
if not hashes:
|
|
798
|
+
add(record.get("clip_name") or "clip")
|
|
799
|
+
return hashes
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
def clip_directory_hash(name: Any) -> Optional[str]:
|
|
803
|
+
"""Extract the trailing stable hash from a clip report folder name.
|
|
804
|
+
|
|
805
|
+
Folder names are ``<label>-<hash>`` where ``<label>`` is the (rename-prone)
|
|
806
|
+
display slug and ``<hash>`` is :func:`stable_clip_hash`. A bare ``<hash>``
|
|
807
|
+
folder (no slug) is also accepted. Returns the hash, or ``None`` if the
|
|
808
|
+
trailing token is not a 12-char hex hash.
|
|
809
|
+
"""
|
|
810
|
+
base = os.path.basename(str(name or "").rstrip("/\\"))
|
|
811
|
+
suffix = base.rsplit("-", 1)[-1]
|
|
812
|
+
if re.fullmatch(r"[0-9a-f]{12}", suffix):
|
|
813
|
+
return suffix
|
|
814
|
+
return None
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
def stable_clip_directory(record: Dict[str, Any]) -> str:
|
|
750
818
|
label = slugify(record.get("clip_name") or Path(str(record.get("file_path") or "clip")).stem, "clip")
|
|
751
|
-
return f"{label}-{
|
|
819
|
+
return f"{label}-{stable_clip_hash(record)}"
|
|
820
|
+
|
|
821
|
+
|
|
822
|
+
def resolve_clip_directory(project_root: str, record: Dict[str, Any]) -> str:
|
|
823
|
+
"""Return the report directory for a clip, reusing an existing one if found.
|
|
824
|
+
|
|
825
|
+
Writes go through here so a clip that was renamed, or analyzed under a legacy
|
|
826
|
+
hash basis (e.g. clip_id-first, or a path-based batch report), reuses its
|
|
827
|
+
existing folder instead of orphaning it under a freshly minted name. Matches
|
|
828
|
+
by canonical hash first, then any legacy hash; falls back to the canonical
|
|
829
|
+
new path when nothing exists yet.
|
|
830
|
+
"""
|
|
831
|
+
clips_root = os.path.join(project_root, "clips")
|
|
832
|
+
# Fast path: the canonical folder already exists by exact name. This is the
|
|
833
|
+
# steady state (re-analysis of an already-canonical clip) and avoids a full
|
|
834
|
+
# directory scan per clip on a batch run.
|
|
835
|
+
canonical_dir = os.path.join(clips_root, stable_clip_directory(record))
|
|
836
|
+
if os.path.isdir(canonical_dir):
|
|
837
|
+
return normalize_path(canonical_dir)
|
|
838
|
+
match = stable_clip_match_hashes(record)
|
|
839
|
+
if match and os.path.isdir(clips_root):
|
|
840
|
+
canonical = match[0]
|
|
841
|
+
match_set = set(match)
|
|
842
|
+
legacy_hit: Optional[str] = None
|
|
843
|
+
try:
|
|
844
|
+
entries = sorted(os.listdir(clips_root))
|
|
845
|
+
except OSError:
|
|
846
|
+
entries = []
|
|
847
|
+
for entry in entries:
|
|
848
|
+
candidate = os.path.join(clips_root, entry)
|
|
849
|
+
if not os.path.isdir(candidate):
|
|
850
|
+
continue
|
|
851
|
+
folder_hash = clip_directory_hash(entry)
|
|
852
|
+
if not folder_hash:
|
|
853
|
+
continue
|
|
854
|
+
if folder_hash == canonical:
|
|
855
|
+
return normalize_path(candidate)
|
|
856
|
+
if folder_hash in match_set and legacy_hit is None:
|
|
857
|
+
legacy_hit = candidate
|
|
858
|
+
if legacy_hit:
|
|
859
|
+
return normalize_path(legacy_hit)
|
|
860
|
+
return normalize_path(os.path.join(clips_root, stable_clip_directory(record)))
|
|
861
|
+
|
|
862
|
+
|
|
863
|
+
CLIP_INDEX_SCHEMA_VERSION = 1
|
|
864
|
+
|
|
865
|
+
|
|
866
|
+
def clip_index_path(project_root: str) -> str:
|
|
867
|
+
"""Path of the per-project clip index (a sidecar under clips/)."""
|
|
868
|
+
return os.path.join(project_root, "clips", "index.json")
|
|
869
|
+
|
|
870
|
+
|
|
871
|
+
def _clip_dir_signature(clips_root: str) -> str:
|
|
872
|
+
"""Cheap fingerprint of the analyzed clip dirs (each analysis.json's name,
|
|
873
|
+
mtime, and size) so the persisted index can be reused until a report is
|
|
874
|
+
added, removed, or rewritten — without reparsing every report each poll."""
|
|
875
|
+
parts: List[str] = []
|
|
876
|
+
try:
|
|
877
|
+
entries = sorted(os.listdir(clips_root))
|
|
878
|
+
except OSError:
|
|
879
|
+
return "0:none"
|
|
880
|
+
for entry in entries:
|
|
881
|
+
report = os.path.join(clips_root, entry, "analysis.json")
|
|
882
|
+
try:
|
|
883
|
+
stat = os.stat(report)
|
|
884
|
+
except OSError:
|
|
885
|
+
continue
|
|
886
|
+
parts.append(f"{entry}:{stat.st_mtime_ns}:{stat.st_size}")
|
|
887
|
+
return f"{len(parts)}:{short_hash('|'.join(parts), 16)}"
|
|
888
|
+
|
|
889
|
+
|
|
890
|
+
def build_clip_index(project_root: str) -> Dict[str, Any]:
|
|
891
|
+
"""Build and persist a hash -> folder index for the project's reports.
|
|
892
|
+
|
|
893
|
+
Unlike a folder-name scan (which only knows the single hash baked into each
|
|
894
|
+
directory name), this reads each report's ``clip`` block and indexes ALL of
|
|
895
|
+
its stable ids (normalized + raw file path, clip_id, media_id). That lets the
|
|
896
|
+
analyzed-count match a clip by any id it still carries — e.g. an offline clip
|
|
897
|
+
that no longer reports a file path but still has its clip_id. See #51.
|
|
898
|
+
"""
|
|
899
|
+
clips_root = os.path.join(project_root, "clips")
|
|
900
|
+
hash_to_folder: Dict[str, str] = {}
|
|
901
|
+
if os.path.isdir(clips_root):
|
|
902
|
+
try:
|
|
903
|
+
entries = sorted(os.listdir(clips_root))
|
|
904
|
+
except OSError:
|
|
905
|
+
entries = []
|
|
906
|
+
for entry in entries:
|
|
907
|
+
report_path = os.path.join(clips_root, entry, "analysis.json")
|
|
908
|
+
if not os.path.isfile(report_path):
|
|
909
|
+
continue
|
|
910
|
+
try:
|
|
911
|
+
report = _read_json(report_path)
|
|
912
|
+
except (OSError, json.JSONDecodeError):
|
|
913
|
+
continue
|
|
914
|
+
clip_block = report.get("clip") if isinstance(report.get("clip"), dict) else {}
|
|
915
|
+
hashes = set(stable_clip_match_hashes(clip_block))
|
|
916
|
+
folder_hash = clip_directory_hash(entry) # the hash baked into the name
|
|
917
|
+
if folder_hash:
|
|
918
|
+
hashes.add(folder_hash)
|
|
919
|
+
for digest in hashes:
|
|
920
|
+
hash_to_folder.setdefault(digest, entry)
|
|
921
|
+
payload = {
|
|
922
|
+
"schema_version": CLIP_INDEX_SCHEMA_VERSION,
|
|
923
|
+
"signature": _clip_dir_signature(clips_root),
|
|
924
|
+
"hash_to_folder": hash_to_folder,
|
|
925
|
+
}
|
|
926
|
+
if os.path.isdir(clips_root):
|
|
927
|
+
try:
|
|
928
|
+
_write_json(clip_index_path(project_root), payload)
|
|
929
|
+
except OSError:
|
|
930
|
+
pass
|
|
931
|
+
return payload
|
|
932
|
+
|
|
933
|
+
|
|
934
|
+
def load_clip_index(project_root: str, *, rebuild_if_stale: bool = True) -> Dict[str, Any]:
|
|
935
|
+
"""Load the persisted clip index, rebuilding it if missing or stale.
|
|
936
|
+
|
|
937
|
+
Freshness is decided by the cheap directory signature, so the common poll
|
|
938
|
+
pays a stat-per-report instead of a full JSON reparse; a rebuild only happens
|
|
939
|
+
when a report is added, removed, or rewritten.
|
|
940
|
+
"""
|
|
941
|
+
clips_root = os.path.join(project_root, "clips")
|
|
942
|
+
current_sig = _clip_dir_signature(clips_root)
|
|
943
|
+
try:
|
|
944
|
+
data = _read_json(clip_index_path(project_root))
|
|
945
|
+
except (OSError, json.JSONDecodeError):
|
|
946
|
+
data = None
|
|
947
|
+
if (
|
|
948
|
+
isinstance(data, dict)
|
|
949
|
+
and data.get("schema_version") == CLIP_INDEX_SCHEMA_VERSION
|
|
950
|
+
and data.get("signature") == current_sig
|
|
951
|
+
and isinstance(data.get("hash_to_folder"), dict)
|
|
952
|
+
):
|
|
953
|
+
return data
|
|
954
|
+
if rebuild_if_stale:
|
|
955
|
+
return build_clip_index(project_root)
|
|
956
|
+
return {
|
|
957
|
+
"schema_version": CLIP_INDEX_SCHEMA_VERSION,
|
|
958
|
+
"signature": current_sig,
|
|
959
|
+
"hash_to_folder": {},
|
|
960
|
+
}
|
|
752
961
|
|
|
753
962
|
|
|
754
963
|
def normalize_path(path: Any) -> str:
|
|
@@ -1681,7 +1890,7 @@ def _bounded_frame_count(depth: str, requested: Any = None) -> int:
|
|
|
1681
1890
|
|
|
1682
1891
|
|
|
1683
1892
|
def _artifact_paths(project_root: str, record: Dict[str, Any], depth: str, options: Dict[str, Any]) -> Dict[str, Any]:
|
|
1684
|
-
clip_dir =
|
|
1893
|
+
clip_dir = resolve_clip_directory(project_root, record)
|
|
1685
1894
|
artifacts: Dict[str, Any] = {
|
|
1686
1895
|
"clip_dir": clip_dir,
|
|
1687
1896
|
"analysis_json": os.path.join(clip_dir, "analysis.json"),
|