davinci-resolve-mcp 2.27.1 → 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 +37 -0
- package/README.md +1 -1
- package/install.py +1 -1
- package/package.json +1 -1
- package/src/analysis_dashboard.py +81 -26
- 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,43 @@
|
|
|
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
|
+
|
|
5
42
|
## What's New in v2.27.1
|
|
6
43
|
|
|
7
44
|
**Faster control-panel startup with network source media (issue #50)** — on
|
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
|
@@ -27,7 +27,10 @@ from src.utils.media_analysis import (
|
|
|
27
27
|
detect_capabilities,
|
|
28
28
|
query_analysis_index,
|
|
29
29
|
resolve_output_root,
|
|
30
|
+
clip_directory_hash,
|
|
31
|
+
load_clip_index,
|
|
30
32
|
stable_clip_directory,
|
|
33
|
+
stable_clip_match_hashes,
|
|
31
34
|
)
|
|
32
35
|
from src.utils.media_analysis_jobs import (
|
|
33
36
|
MEDIA_EXTENSIONS,
|
|
@@ -10912,11 +10915,32 @@ def _analysis_status_by_clip(project_root: str, records: List[Dict[str, Any]]) -
|
|
|
10912
10915
|
status_by_key: Dict[str, Dict[str, Any]] = {}
|
|
10913
10916
|
if not keys:
|
|
10914
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
|
+
|
|
10915
10927
|
for record in records:
|
|
10916
10928
|
clip_key = record.get("clip_key")
|
|
10917
10929
|
if not clip_key:
|
|
10918
10930
|
continue
|
|
10919
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
|
|
10920
10944
|
if os.path.isfile(report_path):
|
|
10921
10945
|
status_by_key[str(clip_key)] = {
|
|
10922
10946
|
"analysis_status": "analyzed",
|
|
@@ -10926,32 +10950,24 @@ def _analysis_status_by_clip(project_root: str, records: List[Dict[str, Any]]) -
|
|
|
10926
10950
|
db_path = os.path.join(project_root, "jobs.sqlite")
|
|
10927
10951
|
if not os.path.isfile(db_path):
|
|
10928
10952
|
return status_by_key
|
|
10929
|
-
|
|
10930
|
-
|
|
10931
|
-
|
|
10932
|
-
|
|
10933
|
-
|
|
10934
|
-
|
|
10935
|
-
|
|
10936
|
-
|
|
10937
|
-
|
|
10938
|
-
|
|
10939
|
-
|
|
10940
|
-
ORDER BY jc.updated_at DESC
|
|
10941
|
-
""",
|
|
10942
|
-
keys,
|
|
10943
|
-
).fetchall()
|
|
10944
|
-
except Exception:
|
|
10945
|
-
return status_by_key
|
|
10946
|
-
finally:
|
|
10947
|
-
try:
|
|
10948
|
-
conn.close()
|
|
10949
|
-
except Exception:
|
|
10950
|
-
pass
|
|
10951
|
-
for row in rows:
|
|
10952
|
-
clip_key = str(row["clip_key"])
|
|
10953
|
-
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:
|
|
10954
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
|
|
10955
10971
|
db_status = row["status"]
|
|
10956
10972
|
report_path = row["report_path"]
|
|
10957
10973
|
# In media_analysis_jobs, 'succeeded' = fresh analysis written this run,
|
|
@@ -10969,7 +10985,7 @@ def _analysis_status_by_clip(project_root: str, records: List[Dict[str, Any]]) -
|
|
|
10969
10985
|
normalized = db_status
|
|
10970
10986
|
if db_status in ("succeeded", "skipped") and report_resolves:
|
|
10971
10987
|
normalized = "analyzed"
|
|
10972
|
-
status_by_key[
|
|
10988
|
+
status_by_key[target_key] = {
|
|
10973
10989
|
"analysis_status": normalized,
|
|
10974
10990
|
"job_status": db_status,
|
|
10975
10991
|
"cache_status": row["cache_status"],
|
|
@@ -10979,6 +10995,45 @@ def _analysis_status_by_clip(project_root: str, records: List[Dict[str, Any]]) -
|
|
|
10979
10995
|
"job_name": row["job_name"],
|
|
10980
10996
|
"job_updated_at": row["updated_at"],
|
|
10981
10997
|
}
|
|
10998
|
+
|
|
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)
|
|
11005
|
+
try:
|
|
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
|
|
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
|
|
10982
11037
|
return status_by_key
|
|
10983
11038
|
|
|
10984
11039
|
|
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"),
|