Spaces:
Running on RTX PRO 6000
Running on RTX PRO 6000
Deploy simready-assets run 20261019-9f72902 (GPU variant) (part 2)
Browse files- pipeline/bake.py +54 -6
- pipeline/categories.py +229 -11
- pipeline/config.py +38 -2
- pipeline/corpus.py +44 -9
- pipeline/enumerate_assets.py +31 -0
- pipeline/export.py +24 -3
- pipeline/hf_space.py +21 -1
- pipeline/hierarchy/run.py +3 -0
- pipeline/hierarchy/store.py +1 -0
- pipeline/image_overrides.py +190 -0
- pipeline/image_overrides_worker.py +291 -0
- pipeline/library_media.py +176 -31
- pipeline/library_media_pack.py +522 -0
- pipeline/package_download/app.py +10 -1
- pipeline/package_download/bundle.py +68 -28
- pipeline/package_download/dedupe.py +97 -0
- pipeline/package_download/settings.py +3 -0
- pipeline/package_download/sizes.py +31 -0
- pipeline/profiles.py +290 -0
- pipeline/restore.py +19 -0
- pipeline/run_pipeline.py +74 -5
- pipeline/status.py +16 -0
- services/explorer/dist/build-info.json +3 -3
- services/explorer/dist/index.html +1 -1
- services/explorer/dist/third-party-notices.txt +1 -1
pipeline/bake.py
CHANGED
|
@@ -186,6 +186,18 @@ def _image_names(cfg: Config) -> Dict[str, str]:
|
|
| 186 |
return names
|
| 187 |
|
| 188 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 189 |
def url_mapping_rules(hf_repos: List[List[str]], asset_counts: Optional[List[List]] = None) -> List[Dict]:
|
| 190 |
"""deepsearch-api's USDSEARCH_URL_MAPPING_RULES for the HF repos a run
|
| 191 |
indexed (manifest["hf_repos"]: host, repo_type, repo_id, revision): each
|
|
@@ -203,20 +215,44 @@ def url_mapping_rules(hf_repos: List[List[str]], asset_counts: Optional[List[Lis
|
|
| 203 |
the revision with the most packages (`asset_counts`, manifest
|
| 204 |
["hf_repo_asset_counts"]: [host, repo_type, repo_id, revision, n]), the
|
| 205 |
one most legacy links point into; without counts, the first listed.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 206 |
"""
|
| 207 |
counts = {tuple(row[:4]): row[4] for row in asset_counts or []}
|
| 208 |
alias_revision: Dict[tuple, str] = {}
|
|
|
|
|
|
|
| 209 |
for host, repo_type, repo_id, revision in hf_repos:
|
|
|
|
| 210 |
if not hf_addressing.looks_like_sha(revision):
|
|
|
|
| 211 |
continue
|
| 212 |
-
repo = (
|
| 213 |
best = alias_revision.get(repo)
|
| 214 |
if best is None or counts.get((*repo, revision), 0) > counts.get((*repo, best), 0):
|
| 215 |
alias_revision[repo] = revision
|
|
|
|
|
|
|
| 216 |
rules = []
|
| 217 |
for host, repo_type, repo_id, revision in hf_repos:
|
|
|
|
| 218 |
prefix = hf_addressing.REPO_TYPE_PREFIX.get(repo_type, "")
|
| 219 |
web = f"https://{host}/" + "/".join(p for p in (prefix, repo_id) if p)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 220 |
rule = {
|
| 221 |
"internal": hf_addressing.build(host=host, repo_type=repo_type, repo_id=repo_id,
|
| 222 |
revision=revision, is_folder=True),
|
|
@@ -264,8 +300,14 @@ def hf_space_build_args(cfg: Config, manifest: Dict) -> Dict[str, str]:
|
|
| 264 |
# go stale when the corpus is re-pinned. As a Space Variable it is both
|
| 265 |
# the build-arg and the runtime env var. Off for an s3:// corpus.
|
| 266 |
rules = url_mapping_rules(manifest.get("hf_repos") or [], manifest.get("hf_repo_asset_counts")) if storage_backend == "storage_api" else []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 267 |
args["USDSEARCH_URL_MAPPING_MODE"] = "rules" if rules else "off"
|
| 268 |
-
args["USDSEARCH_URL_MAPPING_RULES"] =
|
| 269 |
# S3_STORAGE_* is only ever meaningful for an S3-sourced corpus, and
|
| 270 |
# threading a source bucket name through for an HF-sourced one would be
|
| 271 |
# actively misleading.
|
|
@@ -463,14 +505,20 @@ def public_url_smoke(base_url: str, public_prefixes: List[str], timeout_s: float
|
|
| 463 |
is not enough. The stale hand-set rule emitted
|
| 464 |
`https://.../blob/main/resolve/<sha>/...` (a 404), which passes it, so
|
| 465 |
each URL must start with one of `public_prefixes`
|
| 466 |
-
(url_mapping_rules()'s `public`s).
|
| 467 |
-
|
|
|
|
|
|
|
|
|
|
| 468 |
import time
|
| 469 |
import httpx
|
| 470 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 471 |
def bad(urls):
|
| 472 |
-
return [u for u in urls if not (isinstance(u, str) and u.startswith("https://")
|
| 473 |
-
and u.startswith(tuple(public_prefixes)))]
|
| 474 |
|
| 475 |
detail = "no URLs returned"
|
| 476 |
deadline = time.monotonic() + timeout_s
|
|
|
|
| 186 |
return names
|
| 187 |
|
| 188 |
|
| 189 |
+
#: A repo pinned at more revisions than this gets ONE rule instead of one per
|
| 190 |
+
#: revision (url_mapping_rules). Above every repo on the submission lists of
|
| 191 |
+
#: 2026-10-15 (most: 19), so their rules are unchanged.
|
| 192 |
+
COLLAPSE_REVISIONS_ABOVE = 32
|
| 193 |
+
|
| 194 |
+
#: USDSEARCH_URL_MAPPING_RULES is one environment string in the Space image.
|
| 195 |
+
#: Linux refuses any single env or argv string over 128 KiB (MAX_ARG_STRLEN),
|
| 196 |
+
#: so past it every process in the container fails to start; stop the bake
|
| 197 |
+
#: well before that.
|
| 198 |
+
URL_MAPPING_RULES_MAX_BYTES = 100_000
|
| 199 |
+
|
| 200 |
+
|
| 201 |
def url_mapping_rules(hf_repos: List[List[str]], asset_counts: Optional[List[List]] = None) -> List[Dict]:
|
| 202 |
"""deepsearch-api's USDSEARCH_URL_MAPPING_RULES for the HF repos a run
|
| 203 |
indexed (manifest["hf_repos"]: host, repo_type, repo_id, revision): each
|
|
|
|
| 215 |
the revision with the most packages (`asset_counts`, manifest
|
| 216 |
["hf_repo_asset_counts"]: [host, repo_type, repo_id, revision, n]), the
|
| 217 |
one most legacy links point into; without counts, the first listed.
|
| 218 |
+
|
| 219 |
+
A repo pinned at more than COLLAPSE_REVISIONS_ABOVE revisions (one upload
|
| 220 |
+
commit per package: 657 for PalatialSim on 2026-10-15) gets one rule,
|
| 221 |
+
`hf://.../resolve/` -> `.../blob/`, which carries every revision through
|
| 222 |
+
unchanged, so the rules stop growing with the pins. It has no
|
| 223 |
+
`.../blob/main/` alias: search_utils refuses a public prefix that is a
|
| 224 |
+
prefix of another, and `.../blob/` is one of `.../blob/main/`.
|
| 225 |
"""
|
| 226 |
counts = {tuple(row[:4]): row[4] for row in asset_counts or []}
|
| 227 |
alias_revision: Dict[tuple, str] = {}
|
| 228 |
+
pinned: Dict[tuple, int] = {}
|
| 229 |
+
unpinned = set()
|
| 230 |
for host, repo_type, repo_id, revision in hf_repos:
|
| 231 |
+
repo = (host, repo_type, repo_id)
|
| 232 |
if not hf_addressing.looks_like_sha(revision):
|
| 233 |
+
unpinned.add(repo)
|
| 234 |
continue
|
| 235 |
+
pinned[repo] = pinned.get(repo, 0) + 1
|
| 236 |
best = alias_revision.get(repo)
|
| 237 |
if best is None or counts.get((*repo, revision), 0) > counts.get((*repo, best), 0):
|
| 238 |
alias_revision[repo] = revision
|
| 239 |
+
collapsed = {repo for repo, n in pinned.items() if n > COLLAPSE_REVISIONS_ABOVE and repo not in unpinned}
|
| 240 |
+
emitted = set()
|
| 241 |
rules = []
|
| 242 |
for host, repo_type, repo_id, revision in hf_repos:
|
| 243 |
+
repo = (host, repo_type, repo_id)
|
| 244 |
prefix = hf_addressing.REPO_TYPE_PREFIX.get(repo_type, "")
|
| 245 |
web = f"https://{host}/" + "/".join(p for p in (prefix, repo_id) if p)
|
| 246 |
+
if repo in collapsed:
|
| 247 |
+
if repo in emitted:
|
| 248 |
+
continue
|
| 249 |
+
emitted.add(repo)
|
| 250 |
+
rules.append({
|
| 251 |
+
"internal": hf_addressing.build(host=host, repo_type=repo_type, repo_id=repo_id,
|
| 252 |
+
revision="", is_folder=True) + "resolve/",
|
| 253 |
+
"public": f"{web}/blob/",
|
| 254 |
+
})
|
| 255 |
+
continue
|
| 256 |
rule = {
|
| 257 |
"internal": hf_addressing.build(host=host, repo_type=repo_type, repo_id=repo_id,
|
| 258 |
revision=revision, is_folder=True),
|
|
|
|
| 300 |
# go stale when the corpus is re-pinned. As a Space Variable it is both
|
| 301 |
# the build-arg and the runtime env var. Off for an s3:// corpus.
|
| 302 |
rules = url_mapping_rules(manifest.get("hf_repos") or [], manifest.get("hf_repo_asset_counts")) if storage_backend == "storage_api" else []
|
| 303 |
+
rules_json = json.dumps(rules, separators=(",", ":"))
|
| 304 |
+
if len(rules_json.encode()) > URL_MAPPING_RULES_MAX_BYTES:
|
| 305 |
+
raise ValueError(
|
| 306 |
+
f"USDSEARCH_URL_MAPPING_RULES would be {len(rules_json.encode())} bytes ({len(rules)} rules), over "
|
| 307 |
+
f"{URL_MAPPING_RULES_MAX_BYTES}: Linux refuses an env string past 128 KiB, so no process in the Space "
|
| 308 |
+
f"would start. Lower COLLAPSE_REVISIONS_ABOVE or ask the submitter to pin fewer commits.")
|
| 309 |
args["USDSEARCH_URL_MAPPING_MODE"] = "rules" if rules else "off"
|
| 310 |
+
args["USDSEARCH_URL_MAPPING_RULES"] = rules_json
|
| 311 |
# S3_STORAGE_* is only ever meaningful for an S3-sourced corpus, and
|
| 312 |
# threading a source bucket name through for an HF-sourced one would be
|
| 313 |
# actively misleading.
|
|
|
|
| 505 |
is not enough. The stale hand-set rule emitted
|
| 506 |
`https://.../blob/main/resolve/<sha>/...` (a 404), which passes it, so
|
| 507 |
each URL must start with one of `public_prefixes`
|
| 508 |
+
(url_mapping_rules()'s `public`s). Under a collapsed repo's `.../blob/`
|
| 509 |
+
the next segment must be a commit sha, or that stale form would pass.
|
| 510 |
+
Returns (ok, detail). Polls like download_smoke(): the seed restores the
|
| 511 |
+
index after /health is up."""
|
| 512 |
+
import re
|
| 513 |
import time
|
| 514 |
import httpx
|
| 515 |
|
| 516 |
+
def under(u):
|
| 517 |
+
return any(u.startswith(p) and (not p.endswith("/blob/") or re.match(r"[0-9a-f]{40}/", u[len(p):]))
|
| 518 |
+
for p in public_prefixes)
|
| 519 |
+
|
| 520 |
def bad(urls):
|
| 521 |
+
return [u for u in urls if not (isinstance(u, str) and u.startswith("https://") and under(u))]
|
|
|
|
| 522 |
|
| 523 |
detail = "no URLs returned"
|
| 524 |
deadline = time.monotonic() + timeout_s
|
pipeline/categories.py
CHANGED
|
@@ -7,8 +7,10 @@ between `wait` and `export`, every indexed asset gets one more USD property, `ce
|
|
| 7 |
|
| 8 |
* a partner (OEM) package β any dataset owner but NVIDIA's own β gets `oem:<owner>`, the HF dataset owner its
|
| 9 |
simready-central submission list names (`submissions/<owner>/<dataset>`), never vendor-typed metadata;
|
|
|
|
|
|
|
|
|
|
| 10 |
* any other asset gets its `simready_metadata_qcode` value(s);
|
| 11 |
-
* else a pin from config/hierarchy/category_pins.yaml (by package folder);
|
| 12 |
* else the one Q-code its prims carry as a semantic label (`semantic:wikidata_qcode:params:semanticData`);
|
| 13 |
* else nothing (it stays in the tree's Uncategorized bucket; the stage lists it).
|
| 14 |
|
|
@@ -17,6 +19,11 @@ config/hierarchy/vendors.yaml) and a leaf per pin (`augment_hierarchy`), so OEM
|
|
| 17 |
OEM > vendor and the NVIDIA categories stay NVIDIA's. Search, query parsing and every other filter keep reading
|
| 18 |
`simready_metadata_qcode`; only the tree reads `central:category`.
|
| 19 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
python -m pipeline.run_pipeline categories --env-file ... [--dry-run]
|
| 21 |
python -m pipeline.categories hierarchy --env-file ... --from-run <run> --run-id <run> [--dry-run]
|
| 22 |
"""
|
|
@@ -24,7 +31,10 @@ OEM > vendor and the NVIDIA categories stay NVIDIA's. Search, query parsing and
|
|
| 24 |
from __future__ import annotations
|
| 25 |
|
| 26 |
import argparse
|
|
|
|
| 27 |
import json
|
|
|
|
|
|
|
| 28 |
import sys
|
| 29 |
from collections import Counter
|
| 30 |
from pathlib import Path
|
|
@@ -44,6 +54,10 @@ VENDORS_FILE = REPO_ROOT / "config" / "hierarchy" / "vendors.yaml"
|
|
| 44 |
PINS_FILE = REPO_ROOT / "config" / "hierarchy" / "category_pins.yaml"
|
| 45 |
REPORT = BUILD_DIR / "categories.json"
|
| 46 |
HIERARCHY_FILE = "category_hierarchy.json"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
|
| 48 |
|
| 49 |
# ββ config βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
@@ -123,36 +137,41 @@ def category_values(url: str, props: Iterable[Dict], vendors: Dict, pins: Dict[s
|
|
| 123 |
owner, package = split_url(url)
|
| 124 |
if owner and owner.lower() not in vendors["own"]:
|
| 125 |
return [f"{OEM_PREFIX}{owner}"], "oem"
|
|
|
|
|
|
|
| 126 |
qcodes = prop_values(props, QCODE_PROPERTY)
|
| 127 |
if qcodes:
|
| 128 |
return qcodes, "qcode"
|
| 129 |
-
if package and package in pins:
|
| 130 |
-
return [pins[package]["key"]], "pin"
|
| 131 |
semantic = prop_values(props, SEMANTIC_QCODE_PROPERTY)
|
| 132 |
if len(semantic) == 1:
|
| 133 |
return semantic, "semantic"
|
| 134 |
return [], "none"
|
| 135 |
|
| 136 |
|
| 137 |
-
def
|
| 138 |
-
"""`props` without any `
|
| 139 |
-
|
| 140 |
-
kept = [p for p in props or [] if not (isinstance(p, dict) and p.get("name") ==
|
| 141 |
template = next((p for p in kept if isinstance(p, dict) and p.get("value_type") == "string"), None)
|
| 142 |
keys = set(template) if template else {"name", "value", "value_type", "name_sayt", "value_sayt"}
|
| 143 |
out = list(kept)
|
| 144 |
for v in values:
|
| 145 |
-
entry = {"name":
|
| 146 |
if "value_type" in keys:
|
| 147 |
entry["value_type"] = "string"
|
| 148 |
if "name_sayt" in keys:
|
| 149 |
-
entry["name_sayt"] =
|
| 150 |
if "value_sayt" in keys:
|
| 151 |
entry["value_sayt"] = v
|
| 152 |
out.append(entry)
|
| 153 |
return out
|
| 154 |
|
| 155 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 156 |
def _folder_steps(path: str) -> List[Dict]:
|
| 157 |
parts = [p for p in path.split("/") if p]
|
| 158 |
return [{"qcode": FOLDER_PREFIX + "/".join(parts[: i + 1]), "label": parts[i]} for i in range(len(parts))]
|
|
@@ -188,6 +207,202 @@ def augment_hierarchy(doc: Dict, values: Iterable[str], vendors: Dict, pins: Dic
|
|
| 188 |
return out, unplaced
|
| 189 |
|
| 190 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 191 |
# ββ stage: write the property into the live index ββββββββββββββββββββββββββββ
|
| 192 |
|
| 193 |
|
|
@@ -235,6 +450,7 @@ def run(cfg: Config, dry_run: bool = False) -> int:
|
|
| 235 |
unplaced: List[str] = []
|
| 236 |
updates: List[Tuple[str, List[Dict]]] = []
|
| 237 |
urls: List[str] = []
|
|
|
|
| 238 |
sample_printed = False
|
| 239 |
with httpx.Client(timeout=900.0) as http:
|
| 240 |
for hit in _scroll(http, base, index):
|
|
@@ -252,6 +468,7 @@ def run(cfg: Config, dry_run: bool = False) -> int:
|
|
| 252 |
vals, why = category_values(url, props or [], vendors, pins)
|
| 253 |
reasons[why] += 1
|
| 254 |
values.update(vals)
|
|
|
|
| 255 |
if not vals:
|
| 256 |
unplaced.append(url)
|
| 257 |
new_props = with_category(props or [], vals)
|
|
@@ -275,7 +492,7 @@ def run(cfg: Config, dry_run: bool = False) -> int:
|
|
| 275 |
REPORT.write_text(json.dumps({
|
| 276 |
"property": PROPERTY, "index": index, "assets": len(urls), "rules": dict(reasons),
|
| 277 |
"values": dict(sorted(values.items())), "unplaced": unplaced, "problems": problems,
|
| 278 |
-
"dry_run": dry_run,
|
| 279 |
}, indent=1) + "\n")
|
| 280 |
if problems:
|
| 281 |
print("ERROR: " + "\nERROR: ".join(problems))
|
|
@@ -342,6 +559,7 @@ def publish_hierarchy(cfg: Config, from_run: str, run_id: str, dry_run: bool = F
|
|
| 342 |
doc = json.loads(tree_path.read_text())
|
| 343 |
out, unplaced = augment_hierarchy(doc, report["values"].keys(), load_vendors(), load_pins())
|
| 344 |
tree_path.write_text(json.dumps(out, indent=1, ensure_ascii=False, sort_keys=True) + "\n")
|
|
|
|
| 345 |
top = sorted({q["path"][0]["label"] for q in out["qcodes"].values() if q.get("path")})
|
| 346 |
print(f"tree from {from_run}: {len(doc.get('qcodes') or {})} -> {len(out['qcodes'])} entries; top level {top}")
|
| 347 |
if unplaced:
|
|
@@ -351,7 +569,7 @@ def publish_hierarchy(cfg: Config, from_run: str, run_id: str, dry_run: bool = F
|
|
| 351 |
return 0
|
| 352 |
s3.upload_dir(cfg, work, dest)
|
| 353 |
landed = {rel for rel, _ in s3.list_objects(cfg, dest)}
|
| 354 |
-
expected = {
|
| 355 |
if landed != expected:
|
| 356 |
print(f"ERROR: {dest} holds {sorted(landed)}, expected {sorted(expected)}")
|
| 357 |
return 1
|
|
|
|
| 7 |
|
| 8 |
* a partner (OEM) package β any dataset owner but NVIDIA's own β gets `oem:<owner>`, the HF dataset owner its
|
| 9 |
simready-central submission list names (`submissions/<owner>/<dataset>`), never vendor-typed metadata;
|
| 10 |
+
* a package pinned in config/hierarchy/category_pins.yaml (by package folder) gets its pin, even over its
|
| 11 |
+
Q-code: a Q-code that covers unlike objects (one "machine" for a safety shutoff and a brake tester), or
|
| 12 |
+
a package S3 already holds in another category folder;
|
| 13 |
* any other asset gets its `simready_metadata_qcode` value(s);
|
|
|
|
| 14 |
* else the one Q-code its prims carry as a semantic label (`semantic:wikidata_qcode:params:semanticData`);
|
| 15 |
* else nothing (it stays in the tree's Uncategorized bucket; the stage lists it).
|
| 16 |
|
|
|
|
| 19 |
OEM > vendor and the NVIDIA categories stay NVIDIA's. Search, query parsing and every other filter keep reading
|
| 20 |
`simready_metadata_qcode`; only the tree reads `central:category`.
|
| 21 |
|
| 22 |
+
Beside the tree goes `install_categories.json` (`install_list`): every package folder of the corpus's HF datasets
|
| 23 |
+
with the category folder the Space's tree files it under, for installers that copy packages into category folders
|
| 24 |
+
(Michael Mallon's S3 copy). A package maps only when all its values sit in the tree under ONE folder path; anything
|
| 25 |
+
else is `needs_review` with a reason, never a guessed folder. The Space serves it as /install_categories.json.
|
| 26 |
+
|
| 27 |
python -m pipeline.run_pipeline categories --env-file ... [--dry-run]
|
| 28 |
python -m pipeline.categories hierarchy --env-file ... --from-run <run> --run-id <run> [--dry-run]
|
| 29 |
"""
|
|
|
|
| 31 |
from __future__ import annotations
|
| 32 |
|
| 33 |
import argparse
|
| 34 |
+
import datetime as dt
|
| 35 |
import json
|
| 36 |
+
import os
|
| 37 |
+
import re
|
| 38 |
import sys
|
| 39 |
from collections import Counter
|
| 40 |
from pathlib import Path
|
|
|
|
| 54 |
PINS_FILE = REPO_ROOT / "config" / "hierarchy" / "category_pins.yaml"
|
| 55 |
REPORT = BUILD_DIR / "categories.json"
|
| 56 |
HIERARCHY_FILE = "category_hierarchy.json"
|
| 57 |
+
INSTALL_FILE = "install_categories.json"
|
| 58 |
+
INSTALL_SCHEMA = "1.1.0"
|
| 59 |
+
LAST_COMMITS_ENV = "INSTALL_LIST_LAST_COMMITS" # "off": no HF listing (no last commits, no not-indexed rows)
|
| 60 |
+
QCODE_RE = re.compile(r"^Q[1-9]\d*$")
|
| 61 |
|
| 62 |
|
| 63 |
# ββ config βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 137 |
owner, package = split_url(url)
|
| 138 |
if owner and owner.lower() not in vendors["own"]:
|
| 139 |
return [f"{OEM_PREFIX}{owner}"], "oem"
|
| 140 |
+
if package and package in pins:
|
| 141 |
+
return [pins[package]["key"]], "pin"
|
| 142 |
qcodes = prop_values(props, QCODE_PROPERTY)
|
| 143 |
if qcodes:
|
| 144 |
return qcodes, "qcode"
|
|
|
|
|
|
|
| 145 |
semantic = prop_values(props, SEMANTIC_QCODE_PROPERTY)
|
| 146 |
if len(semantic) == 1:
|
| 147 |
return semantic, "semantic"
|
| 148 |
return [], "none"
|
| 149 |
|
| 150 |
|
| 151 |
+
def with_property(props: List[Dict], name: str, values: List[str]) -> List[Dict]:
|
| 152 |
+
"""`props` without any `name` entry, plus one entry per value, shaped like the props' own string entries
|
| 153 |
+
(name/value/value_type and their `_sayt` twins where the index has them)."""
|
| 154 |
+
kept = [p for p in props or [] if not (isinstance(p, dict) and p.get("name") == name)]
|
| 155 |
template = next((p for p in kept if isinstance(p, dict) and p.get("value_type") == "string"), None)
|
| 156 |
keys = set(template) if template else {"name", "value", "value_type", "name_sayt", "value_sayt"}
|
| 157 |
out = list(kept)
|
| 158 |
for v in values:
|
| 159 |
+
entry = {"name": name, "value": v}
|
| 160 |
if "value_type" in keys:
|
| 161 |
entry["value_type"] = "string"
|
| 162 |
if "name_sayt" in keys:
|
| 163 |
+
entry["name_sayt"] = name
|
| 164 |
if "value_sayt" in keys:
|
| 165 |
entry["value_sayt"] = v
|
| 166 |
out.append(entry)
|
| 167 |
return out
|
| 168 |
|
| 169 |
|
| 170 |
+
def with_category(props: List[Dict], values: List[str]) -> List[Dict]:
|
| 171 |
+
"""`props` with its `central:category` entries replaced by `values`."""
|
| 172 |
+
return with_property(props, PROPERTY, values)
|
| 173 |
+
|
| 174 |
+
|
| 175 |
def _folder_steps(path: str) -> List[Dict]:
|
| 176 |
parts = [p for p in path.split("/") if p]
|
| 177 |
return [{"qcode": FOLDER_PREFIX + "/".join(parts[: i + 1]), "label": parts[i]} for i in range(len(parts))]
|
|
|
|
| 207 |
return out, unplaced
|
| 208 |
|
| 209 |
|
| 210 |
+
# ββ install list: package folder -> category folder ββββββββββββββββββββββββββ
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
def package_address(url: str) -> Optional[Tuple[str, str, str]]:
|
| 214 |
+
"""(repo id, revision, top-level package folder) of an hf:// address or its public https blob/resolve URL."""
|
| 215 |
+
if url.startswith("hf://"):
|
| 216 |
+
try:
|
| 217 |
+
addr = hf_addressing.parse(url)
|
| 218 |
+
except hf_addressing.AddressError:
|
| 219 |
+
return None
|
| 220 |
+
folder = addr.path.split("/")[0]
|
| 221 |
+
return (addr.repo_id, addr.revision or "", folder) if folder else None
|
| 222 |
+
parts = url.split("/")
|
| 223 |
+
if len(parts) > 9 and parts[3] == "datasets" and parts[6] in ("blob", "resolve") and parts[8]:
|
| 224 |
+
return f"{parts[4]}/{parts[5]}", parts[7], parts[8]
|
| 225 |
+
return None
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
def tree_folder(rec: Dict) -> Optional[str]:
|
| 229 |
+
"""The folder a tree entry files its assets under: the labels of its `folder:` steps, plus the vendor for an
|
| 230 |
+
OEM entry (OEM > LG Electronics). None when the entry has no folder."""
|
| 231 |
+
steps = rec.get("path") or []
|
| 232 |
+
labels = [str(s.get("label") or "") for s in steps if str(s.get("qcode") or "").startswith(FOLDER_PREFIX)]
|
| 233 |
+
if steps and str(steps[-1].get("qcode") or "").startswith(OEM_PREFIX):
|
| 234 |
+
labels.append(str(steps[-1].get("label") or ""))
|
| 235 |
+
return "/".join(labels) if labels else None
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
def safe_path(path: str) -> bool:
|
| 239 |
+
"""Every segment a plain folder name: not empty, not . or .., no backslash or control character."""
|
| 240 |
+
return all(seg.strip() and seg not in (".", "..") and "\\" not in seg and not any(ord(ch) < 32 for ch in seg)
|
| 241 |
+
for seg in path.split("/"))
|
| 242 |
+
|
| 243 |
+
|
| 244 |
+
def _last_commit(raw) -> Optional[Dict]:
|
| 245 |
+
if not raw:
|
| 246 |
+
return None
|
| 247 |
+
if isinstance(raw, dict):
|
| 248 |
+
oid, title, date = raw.get("id") or raw.get("oid"), raw.get("title"), raw.get("date")
|
| 249 |
+
else:
|
| 250 |
+
oid, title, date = getattr(raw, "oid", None), getattr(raw, "title", None), getattr(raw, "date", None)
|
| 251 |
+
if isinstance(date, dt.datetime):
|
| 252 |
+
date = date.astimezone(dt.timezone.utc).isoformat().replace("+00:00", "Z")
|
| 253 |
+
return {"id": oid, "date": date, "title": title} if oid else None
|
| 254 |
+
|
| 255 |
+
|
| 256 |
+
def package_listings(repos: Iterable[Tuple[str, str]], api=None) -> Tuple[Dict[Tuple[str, str], Dict], str]:
|
| 257 |
+
"""The top-level folders of each (repo id, revision) with their last commit at that revision: one
|
| 258 |
+
non-recursive expanded tree listing each (Hugging Face pages expanded listings 50 at a time). Fail-soft: what
|
| 259 |
+
it could list, and a note saying what it could not."""
|
| 260 |
+
from huggingface_hub import RepoFolder
|
| 261 |
+
|
| 262 |
+
if api is None:
|
| 263 |
+
from .enumerate_assets import _hf_api
|
| 264 |
+
api = _hf_api()
|
| 265 |
+
out: Dict[Tuple[str, str], Dict] = {}
|
| 266 |
+
failed: List[str] = []
|
| 267 |
+
for repo, rev in sorted(set(repos)):
|
| 268 |
+
try:
|
| 269 |
+
entries = api.list_repo_tree(repo, recursive=False, revision=rev or None, repo_type="dataset", expand=True)
|
| 270 |
+
out[(repo, rev)] = {e.path: _last_commit(getattr(e, "last_commit", None))
|
| 271 |
+
for e in entries if isinstance(e, RepoFolder) and not e.path.startswith(".")}
|
| 272 |
+
except Exception as exc: # noqa: BLE001 β the list ships without last commits rather than not at all
|
| 273 |
+
failed.append(f"{repo}@{rev[:8]}: {type(exc).__name__}: {str(exc)[:160]}")
|
| 274 |
+
return out, ("listed" if not failed else "not listed for " + "; ".join(failed))
|
| 275 |
+
|
| 276 |
+
|
| 277 |
+
def install_list(assets: Iterable[Dict], tree: Dict, *, own: Iterable[str], listings: Optional[Dict] = None,
|
| 278 |
+
run_id: str = "", corpus: str = "", last_commits: str = "", generated_at: str = "") -> Dict:
|
| 279 |
+
"""`install_categories.json`: every package folder -> the category folder of the Space's tree.
|
| 280 |
+
|
| 281 |
+
`assets` are the categories stage's per-file rows ({url, values, rule}); `tree` the augmented
|
| 282 |
+
category_hierarchy.json; `listings` the HF top-level folders per (repo, revision) from `package_listings`
|
| 283 |
+
(their last commits, and the folders of our own datasets that no indexed file covers: `not_indexed`).
|
| 284 |
+
The primary repository (our own dataset with the most packages) is keyed by bare folder, like the 2026-10-13
|
| 285 |
+
export; any other dataset as `<owner>/<name>/<folder>`."""
|
| 286 |
+
own = {o.lower() for o in own}
|
| 287 |
+
places = tree.get("qcodes") or {}
|
| 288 |
+
listings = listings or {}
|
| 289 |
+
packages: Dict[Tuple[str, str], Dict] = {}
|
| 290 |
+
for a in assets:
|
| 291 |
+
addr = package_address(a.get("url") or "")
|
| 292 |
+
if not addr:
|
| 293 |
+
continue
|
| 294 |
+
repo, rev, folder = addr
|
| 295 |
+
p = packages.setdefault((repo, folder), {"revisions": set(), "values": set(), "rules": set(), "files": 0})
|
| 296 |
+
p["files"] += 1
|
| 297 |
+
p["revisions"].add(rev)
|
| 298 |
+
p["values"].update(a.get("values") or [])
|
| 299 |
+
p["rules"].add(a.get("rule") or "none")
|
| 300 |
+
for (repo, rev), folders in listings.items():
|
| 301 |
+
if repo.split("/")[0].lower() not in own:
|
| 302 |
+
continue # a partner's dataset holds more than its submission list: only listed packages count
|
| 303 |
+
for folder in folders:
|
| 304 |
+
packages.setdefault((repo, folder), {"revisions": set(), "values": set(), "rules": set(), "files": 0})
|
| 305 |
+
if not packages[(repo, folder)]["files"]:
|
| 306 |
+
packages[(repo, folder)]["revisions"].add(rev)
|
| 307 |
+
|
| 308 |
+
per_repo = Counter(repo for repo, _ in packages)
|
| 309 |
+
ours = [r for r in per_repo if r.split("/")[0].lower() in own]
|
| 310 |
+
primary = max(ours or per_repo, key=lambda r: (per_repo[r], r)) if per_repo else ""
|
| 311 |
+
|
| 312 |
+
rows: Dict[str, Dict] = {}
|
| 313 |
+
for (repo, folder), p in sorted(packages.items()):
|
| 314 |
+
values = sorted(p["values"])
|
| 315 |
+
revs = sorted(r for r in p["revisions"] if r)
|
| 316 |
+
commit = next((listings.get((repo, r), {}).get(folder) for r in revs if listings.get((repo, r), {}).get(folder)), None)
|
| 317 |
+
row = {"asset_path": folder, "repository": repo, "revision": revs[0] if len(revs) == 1 else None,
|
| 318 |
+
"last_commit": commit, "qcodes": [v for v in values if QCODE_RE.match(v)], "category_keys": values,
|
| 319 |
+
"rules": sorted(p["rules"]), "files": p["files"],
|
| 320 |
+
"category_path": None, "install_path": None, "status": "needs_review", "reason": None}
|
| 321 |
+
unplaced = [v for v in values if v not in places]
|
| 322 |
+
paths = {tree_folder(places[v]) for v in values if v in places}
|
| 323 |
+
if not p["files"]:
|
| 324 |
+
row["reason"] = "not_indexed"
|
| 325 |
+
elif len(revs) > 1:
|
| 326 |
+
row["reason"], row["revisions"] = "several_revisions", revs
|
| 327 |
+
elif not values:
|
| 328 |
+
row["reason"] = "missing_qcode"
|
| 329 |
+
elif unplaced:
|
| 330 |
+
row["reason"], row["unplaced"] = "qcode_not_in_tree", unplaced
|
| 331 |
+
elif None in paths:
|
| 332 |
+
row["reason"] = "no_category_folder"
|
| 333 |
+
elif len(paths) > 1:
|
| 334 |
+
row["reason"], row["category_paths"] = "multiple_category_paths", sorted(paths)
|
| 335 |
+
elif not safe_path(next(iter(paths))):
|
| 336 |
+
row["reason"], row["category_paths"] = "unsafe_category_path", sorted(paths)
|
| 337 |
+
else:
|
| 338 |
+
path = next(iter(paths))
|
| 339 |
+
row.update(category_path=path, install_path=f"{path}/{folder}", status="mapped")
|
| 340 |
+
rows[folder if repo == primary else f"{repo}/{folder}"] = row
|
| 341 |
+
|
| 342 |
+
by_dest: Dict[str, List[str]] = {}
|
| 343 |
+
for key, row in rows.items():
|
| 344 |
+
if row["install_path"]:
|
| 345 |
+
by_dest.setdefault(row["install_path"].casefold(), []).append(key)
|
| 346 |
+
for keys in by_dest.values():
|
| 347 |
+
if len(keys) > 1: # two packages would land in one folder (a case-insensitive file system included)
|
| 348 |
+
for key in keys:
|
| 349 |
+
rows[key].update(category_paths=[rows[key]["category_path"]], category_path=None, install_path=None,
|
| 350 |
+
status="needs_review", reason="install_path_collision")
|
| 351 |
+
|
| 352 |
+
mapped = [r for r in rows.values() if r["status"] == "mapped"]
|
| 353 |
+
repos = sorted(per_repo)
|
| 354 |
+
revisions = {repo: dict(sorted(Counter(r["revision"] for r in rows.values()
|
| 355 |
+
if r["repository"] == repo and r["revision"]).items())) for repo in repos}
|
| 356 |
+
coverage = {
|
| 357 |
+
"assets": len(rows), "mapped_assets": len(mapped), "needs_review_assets": len(rows) - len(mapped),
|
| 358 |
+
"reasons": dict(sorted(Counter(r["reason"] for r in rows.values() if r["reason"]).items())),
|
| 359 |
+
"repositories": {repo: {"assets": sum(1 for r in rows.values() if r["repository"] == repo),
|
| 360 |
+
"mapped_assets": sum(1 for r in mapped if r["repository"] == repo)} for repo in repos},
|
| 361 |
+
"assets_with_qcode": sum(1 for r in rows.values() if r["qcodes"]),
|
| 362 |
+
"qcodes": len({q for r in rows.values() for q in r["qcodes"]}),
|
| 363 |
+
"mapped_qcodes": len({q for r in mapped for q in r["qcodes"]}),
|
| 364 |
+
"unplaced_qcodes": sorted({v for r in rows.values() for v in r.get("unplaced") or []}),
|
| 365 |
+
}
|
| 366 |
+
primary_revs = list(revisions.get(primary) or {})
|
| 367 |
+
provenance = {
|
| 368 |
+
"repository": primary, "repository_url": f"https://huggingface.co/datasets/{primary}" if primary else None,
|
| 369 |
+
"revision": primary_revs[0] if len(primary_revs) == 1 else None, "revisions": revisions,
|
| 370 |
+
"corpus": corpus, "run_id": run_id,
|
| 371 |
+
"tree": {"file": HIERARCHY_FILE, "run_id": run_id, "generated_at": tree.get("generated_at"),
|
| 372 |
+
"qcode_property": tree.get("qcode_property"), "source_qcodes": tree.get("source_qcodes"),
|
| 373 |
+
"entries": len(places)},
|
| 374 |
+
"last_commits": last_commits or ("listed" if listings else "off"),
|
| 375 |
+
"method": f"each package's {PROPERTY} values (pipeline/categories.py) joined to the run's Category tree; "
|
| 376 |
+
"a package maps only when every value sits under one folder path; nothing is guessed",
|
| 377 |
+
}
|
| 378 |
+
return {"schema_version": INSTALL_SCHEMA,
|
| 379 |
+
"generated_at": generated_at or dt.datetime.now(dt.timezone.utc).replace(microsecond=0).isoformat(),
|
| 380 |
+
"provenance": provenance, "coverage": coverage, "assets": rows}
|
| 381 |
+
|
| 382 |
+
|
| 383 |
+
def write_install_list(out_dir: Path, tree: Dict, *, run_id: str = "", corpus: str = "") -> Optional[Path]:
|
| 384 |
+
"""Write `install_categories.json` into the run's hierarchy dir from the categories report. None (and a
|
| 385 |
+
warning) when the report predates per-asset rows."""
|
| 386 |
+
report = json.loads(REPORT.read_text())
|
| 387 |
+
path = out_dir / INSTALL_FILE
|
| 388 |
+
if "assets" not in report:
|
| 389 |
+
path.unlink(missing_ok=True) # never ship an earlier run's list with this run's tree
|
| 390 |
+
print(f"WARNING: {REPORT} has no per-asset rows (re-run the `categories` stage): no {INSTALL_FILE}")
|
| 391 |
+
return None
|
| 392 |
+
repos = {addr[:2] for addr in (package_address(a.get("url") or "") for a in report["assets"]) if addr}
|
| 393 |
+
if os.environ.get(LAST_COMMITS_ENV, "").strip().lower() == "off":
|
| 394 |
+
listings, note = {}, "off"
|
| 395 |
+
else:
|
| 396 |
+
listings, note = package_listings(repos)
|
| 397 |
+
doc = install_list(report["assets"], tree, own=load_vendors()["own"], listings=listings, run_id=run_id,
|
| 398 |
+
corpus=corpus, last_commits=note)
|
| 399 |
+
path.write_text(json.dumps(doc, indent=1, ensure_ascii=False, sort_keys=True) + "\n")
|
| 400 |
+
cov = doc["coverage"]
|
| 401 |
+
print(f"{INSTALL_FILE}: {cov['mapped_assets']}/{cov['assets']} packages mapped; needs review {cov['reasons']}; "
|
| 402 |
+
f"last commits: {note}")
|
| 403 |
+
return path
|
| 404 |
+
|
| 405 |
+
|
| 406 |
# ββ stage: write the property into the live index ββββββββββββββββββββββββββββ
|
| 407 |
|
| 408 |
|
|
|
|
| 450 |
unplaced: List[str] = []
|
| 451 |
updates: List[Tuple[str, List[Dict]]] = []
|
| 452 |
urls: List[str] = []
|
| 453 |
+
rows: List[Dict] = [] # per file: the install list's input (install_list)
|
| 454 |
sample_printed = False
|
| 455 |
with httpx.Client(timeout=900.0) as http:
|
| 456 |
for hit in _scroll(http, base, index):
|
|
|
|
| 468 |
vals, why = category_values(url, props or [], vendors, pins)
|
| 469 |
reasons[why] += 1
|
| 470 |
values.update(vals)
|
| 471 |
+
rows.append({"url": url, "values": vals, "rule": why})
|
| 472 |
if not vals:
|
| 473 |
unplaced.append(url)
|
| 474 |
new_props = with_category(props or [], vals)
|
|
|
|
| 492 |
REPORT.write_text(json.dumps({
|
| 493 |
"property": PROPERTY, "index": index, "assets": len(urls), "rules": dict(reasons),
|
| 494 |
"values": dict(sorted(values.items())), "unplaced": unplaced, "problems": problems,
|
| 495 |
+
"dry_run": dry_run, "assets": rows,
|
| 496 |
}, indent=1) + "\n")
|
| 497 |
if problems:
|
| 498 |
print("ERROR: " + "\nERROR: ".join(problems))
|
|
|
|
| 559 |
doc = json.loads(tree_path.read_text())
|
| 560 |
out, unplaced = augment_hierarchy(doc, report["values"].keys(), load_vendors(), load_pins())
|
| 561 |
tree_path.write_text(json.dumps(out, indent=1, ensure_ascii=False, sort_keys=True) + "\n")
|
| 562 |
+
write_install_list(work, out, run_id=run_id, corpus=corpus)
|
| 563 |
top = sorted({q["path"][0]["label"] for q in out["qcodes"].values() if q.get("path")})
|
| 564 |
print(f"tree from {from_run}: {len(doc.get('qcodes') or {})} -> {len(out['qcodes'])} entries; top level {top}")
|
| 565 |
if unplaced:
|
|
|
|
| 569 |
return 0
|
| 570 |
s3.upload_dir(cfg, work, dest)
|
| 571 |
landed = {rel for rel, _ in s3.list_objects(cfg, dest)}
|
| 572 |
+
expected = {p.relative_to(work).as_posix() for p in work.rglob("*") if p.is_file()}
|
| 573 |
if landed != expected:
|
| 574 |
print(f"ERROR: {dest} holds {sorted(landed)}, expected {sorted(expected)}")
|
| 575 |
return 1
|
pipeline/config.py
CHANGED
|
@@ -107,6 +107,7 @@ class Config:
|
|
| 107 |
"INFO_ENDPOINT_URL",
|
| 108 |
"OPENSEARCH_URL",
|
| 109 |
"SIGLIP2_BACKEND",
|
|
|
|
| 110 |
"SIGLIP2_NVCF_ENDPOINT",
|
| 111 |
"STORAGE_BACKEND_TYPE",
|
| 112 |
"SIGLIP2_NVCF_FUNCTION_ID",
|
|
@@ -211,6 +212,24 @@ class Config:
|
|
| 211 |
def siglip2_nvcf(self) -> bool:
|
| 212 |
return self.siglip2_backend == "nvcf"
|
| 213 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 214 |
# ββ HF Space GPU variant (local siglip2-triton, no NVCF) ββββββββββββββββ
|
| 215 |
# Independent of SIGLIP2_BACKEND above (which is the *indexing stack's*
|
| 216 |
# compose backend, local-GPU vs. NVCF) β this is a second, optional
|
|
@@ -451,6 +470,23 @@ class Config:
|
|
| 451 |
"../usdsearch/docs/models-and-config.md)."
|
| 452 |
)
|
| 453 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 454 |
def compose_env(self) -> Dict[str, str]:
|
| 455 |
"""The environment every `docker compose`/`docker run` subprocess
|
| 456 |
this pipeline spawns should get β `pipeline.env`'s values plus
|
|
@@ -482,7 +518,7 @@ class Config:
|
|
| 482 |
# so operators only ever set one variable. Verified live: without
|
| 483 |
# this, hf-storage-api silently ran unauthenticated and every
|
| 484 |
# submission against a private dataset 401'd as "Asset missing".
|
| 485 |
-
hf_token =
|
| 486 |
if hf_token:
|
| 487 |
env["HF_ADAPTER_HF_TOKEN"] = hf_token
|
| 488 |
# No vlm_metadata_enabled-specific merge needed here: `env` already
|
|
@@ -496,7 +532,7 @@ class Config:
|
|
| 496 |
|
| 497 |
def indexing_compose_files(self) -> List[str]:
|
| 498 |
files = ["-f", str(REPO_ROOT / "compose" / "docker-compose.indexing.yml")]
|
| 499 |
-
if self.
|
| 500 |
files += ["-f", str(REPO_ROOT / "compose" / "docker-compose.indexing.nvcf-siglip2.yml")]
|
| 501 |
# Only the indexing stack ever talks to a storage backend directly β
|
| 502 |
# the runtime stack (verify/HF Space) serves an already-baked index and
|
|
|
|
| 107 |
"INFO_ENDPOINT_URL",
|
| 108 |
"OPENSEARCH_URL",
|
| 109 |
"SIGLIP2_BACKEND",
|
| 110 |
+
"INDEXING_SIGLIP2_BACKEND",
|
| 111 |
"SIGLIP2_NVCF_ENDPOINT",
|
| 112 |
"STORAGE_BACKEND_TYPE",
|
| 113 |
"SIGLIP2_NVCF_FUNCTION_ID",
|
|
|
|
| 212 |
def siglip2_nvcf(self) -> bool:
|
| 213 |
return self.siglip2_backend == "nvcf"
|
| 214 |
|
| 215 |
+
# SIGLIP2_BACKEND also decides what serves queries (the verify stack, the
|
| 216 |
+
# dev Space's NVCF-only hf_space image), so the indexing stack may override
|
| 217 |
+
# it: e.g. embed the corpus on the local GPU while the dev Space keeps
|
| 218 |
+
# querying NVCF. Same model either way, so the vectors are interchangeable.
|
| 219 |
+
|
| 220 |
+
@property
|
| 221 |
+
def indexing_siglip2_backend(self) -> str:
|
| 222 |
+
backend = self.get("INDEXING_SIGLIP2_BACKEND").strip().lower()
|
| 223 |
+
if not backend:
|
| 224 |
+
return self.siglip2_backend
|
| 225 |
+
if backend not in ("local", "nvcf"):
|
| 226 |
+
raise SystemExit(f"INDEXING_SIGLIP2_BACKEND must be 'local' or 'nvcf', got {backend!r}")
|
| 227 |
+
return backend
|
| 228 |
+
|
| 229 |
+
@property
|
| 230 |
+
def indexing_siglip2_nvcf(self) -> bool:
|
| 231 |
+
return self.indexing_siglip2_backend == "nvcf"
|
| 232 |
+
|
| 233 |
# ββ HF Space GPU variant (local siglip2-triton, no NVCF) ββββββββββββββββ
|
| 234 |
# Independent of SIGLIP2_BACKEND above (which is the *indexing stack's*
|
| 235 |
# compose backend, local-GPU vs. NVCF) β this is a second, optional
|
|
|
|
| 470 |
"../usdsearch/docs/models-and-config.md)."
|
| 471 |
)
|
| 472 |
|
| 473 |
+
@staticmethod
|
| 474 |
+
def hf_token() -> str:
|
| 475 |
+
"""$HF_TOKEN, else the token `hf auth login` cached β the same lookup
|
| 476 |
+
the enumerator's huggingface_hub does on its own. The container only
|
| 477 |
+
sees what compose_env() passes it, so without this fallback it ran
|
| 478 |
+
anonymously whenever $HF_TOKEN wasn't exported, and a public dataset
|
| 479 |
+
still "worked": HF's anonymous per-IP API quota (per 5-minute window)
|
| 480 |
+
throttled a 2,600-package re-index to ~2 asset graphs/min with 429s."""
|
| 481 |
+
token = os.environ.get("HF_TOKEN", "").strip()
|
| 482 |
+
if token:
|
| 483 |
+
return token
|
| 484 |
+
try:
|
| 485 |
+
from huggingface_hub import get_token
|
| 486 |
+
except ImportError:
|
| 487 |
+
return ""
|
| 488 |
+
return (get_token() or "").strip()
|
| 489 |
+
|
| 490 |
def compose_env(self) -> Dict[str, str]:
|
| 491 |
"""The environment every `docker compose`/`docker run` subprocess
|
| 492 |
this pipeline spawns should get β `pipeline.env`'s values plus
|
|
|
|
| 518 |
# so operators only ever set one variable. Verified live: without
|
| 519 |
# this, hf-storage-api silently ran unauthenticated and every
|
| 520 |
# submission against a private dataset 401'd as "Asset missing".
|
| 521 |
+
hf_token = self.hf_token()
|
| 522 |
if hf_token:
|
| 523 |
env["HF_ADAPTER_HF_TOKEN"] = hf_token
|
| 524 |
# No vlm_metadata_enabled-specific merge needed here: `env` already
|
|
|
|
| 532 |
|
| 533 |
def indexing_compose_files(self) -> List[str]:
|
| 534 |
files = ["-f", str(REPO_ROOT / "compose" / "docker-compose.indexing.yml")]
|
| 535 |
+
if self.indexing_siglip2_nvcf:
|
| 536 |
files += ["-f", str(REPO_ROOT / "compose" / "docker-compose.indexing.nvcf-siglip2.yml")]
|
| 537 |
# Only the indexing stack ever talks to a storage backend directly β
|
| 538 |
# the runtime stack (verify/HF Space) serves an already-baked index and
|
pipeline/corpus.py
CHANGED
|
@@ -9,6 +9,8 @@ exactly the kind of thing that silently indexes the wrong 200k assets.
|
|
| 9 |
|
| 10 |
from __future__ import annotations
|
| 11 |
|
|
|
|
|
|
|
| 12 |
import hashlib
|
| 13 |
import json
|
| 14 |
from dataclasses import dataclass
|
|
@@ -46,6 +48,25 @@ class CorpusError(ValueError):
|
|
| 46 |
"""Raised for a corpus file that cannot be used as written."""
|
| 47 |
|
| 48 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
@dataclass(frozen=True)
|
| 50 |
class S3Prefix:
|
| 51 |
bucket: str
|
|
@@ -201,13 +222,25 @@ class Corpus:
|
|
| 201 |
|
| 202 |
# ββ matching ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 203 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 204 |
def is_excluded(self, uri: str) -> bool:
|
| 205 |
if any(s in uri for s in ALWAYS_EXCLUDE_SUBSTRINGS):
|
| 206 |
return True
|
| 207 |
-
return
|
| 208 |
|
| 209 |
def is_included(self, uri: str) -> bool:
|
| 210 |
-
return
|
| 211 |
|
| 212 |
def has_wanted_extension(self, uri: str) -> bool:
|
| 213 |
lowered = uri.lower()
|
|
@@ -318,13 +351,15 @@ class Corpus:
|
|
| 318 |
|
| 319 |
# A prefix nested inside another include is redundant, not wrong β but
|
| 320 |
# it usually signals a misunderstanding of the trailing-slash rule.
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
|
|
|
|
|
|
| 328 |
|
| 329 |
# A stray exclude β one that sits under no include β is nearly always a
|
| 330 |
# typo, and it silently does nothing.
|
|
|
|
| 9 |
|
| 10 |
from __future__ import annotations
|
| 11 |
|
| 12 |
+
import bisect
|
| 13 |
+
import functools
|
| 14 |
import hashlib
|
| 15 |
import json
|
| 16 |
from dataclasses import dataclass
|
|
|
|
| 48 |
"""Raised for a corpus file that cannot be used as written."""
|
| 49 |
|
| 50 |
|
| 51 |
+
def _prefix_index(uris: Iterable[str]) -> List[str]:
|
| 52 |
+
"""Sorted, with every entry another entry already covers dropped β so at
|
| 53 |
+
most one entry is a prefix of any string (see `_has_prefix_in`)."""
|
| 54 |
+
index: List[str] = []
|
| 55 |
+
for uri in sorted(set(uris)):
|
| 56 |
+
if not (index and uri.startswith(index[-1])):
|
| 57 |
+
index.append(uri)
|
| 58 |
+
return index
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def _has_prefix_in(index: List[str], uri: str) -> bool:
|
| 62 |
+
"""Whether some entry of `index` (from `_prefix_index`) is a prefix of
|
| 63 |
+
`uri`. Only the greatest entry <= `uri` can be: any entry between a prefix
|
| 64 |
+
p and `uri` either starts with p (dropped from the index) or sorts after
|
| 65 |
+
`uri`."""
|
| 66 |
+
i = bisect.bisect_right(index, uri)
|
| 67 |
+
return i > 0 and uri.startswith(index[i - 1])
|
| 68 |
+
|
| 69 |
+
|
| 70 |
@dataclass(frozen=True)
|
| 71 |
class S3Prefix:
|
| 72 |
bucket: str
|
|
|
|
| 222 |
|
| 223 |
# ββ matching ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 224 |
|
| 225 |
+
# `enumerate` tests every file of every listed repo tree (400k on 2026-10-19)
|
| 226 |
+
# against every include (3,569), so a linear startswith scan took ~25 min.
|
| 227 |
+
# Same literal-prefix semantics, one bisect per lookup: see _prefix_index.
|
| 228 |
+
|
| 229 |
+
@functools.cached_property
|
| 230 |
+
def _include_index(self) -> List[str]:
|
| 231 |
+
return _prefix_index(p.uri for p in self.include)
|
| 232 |
+
|
| 233 |
+
@functools.cached_property
|
| 234 |
+
def _exclude_index(self) -> List[str]:
|
| 235 |
+
return _prefix_index(p.uri for p in self.exclude)
|
| 236 |
+
|
| 237 |
def is_excluded(self, uri: str) -> bool:
|
| 238 |
if any(s in uri for s in ALWAYS_EXCLUDE_SUBSTRINGS):
|
| 239 |
return True
|
| 240 |
+
return _has_prefix_in(self._exclude_index, uri)
|
| 241 |
|
| 242 |
def is_included(self, uri: str) -> bool:
|
| 243 |
+
return _has_prefix_in(self._include_index, uri)
|
| 244 |
|
| 245 |
def has_wanted_extension(self, uri: str) -> bool:
|
| 246 |
lowered = uri.lower()
|
|
|
|
| 351 |
|
| 352 |
# A prefix nested inside another include is redundant, not wrong β but
|
| 353 |
# it usually signals a misunderstanding of the trailing-slash rule.
|
| 354 |
+
# Sorted, a prefix's immediate successor starts with it whenever any
|
| 355 |
+
# entry does, so adjacent pairs are enough (duplicates are gone above).
|
| 356 |
+
uris = sorted(seen)
|
| 357 |
+
for b, a in zip(uris, uris[1:]):
|
| 358 |
+
if a.startswith(b):
|
| 359 |
+
raise CorpusError(
|
| 360 |
+
f"include: {a!r} is already covered by {b!r}; "
|
| 361 |
+
f"remove the redundant entry"
|
| 362 |
+
)
|
| 363 |
|
| 364 |
# A stray exclude β one that sits under no include β is nearly always a
|
| 365 |
# typo, and it silently does nothing.
|
pipeline/enumerate_assets.py
CHANGED
|
@@ -131,6 +131,33 @@ def _hf_api():
|
|
| 131 |
return HfApi(token=os.environ.get("HF_TOKEN") or None)
|
| 132 |
|
| 133 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
def enumerate_corpus_hf(corpus: Corpus, cfg: Config) -> Tuple[List[Asset], Dict[hf_catalog.RepoKey, List[str]]]:
|
| 135 |
"""The `storage_api` backend's counterpart to `enumerate_corpus` β lists
|
| 136 |
each corpus HF repo's full file tree, applies the same include/exclude/
|
|
@@ -144,6 +171,7 @@ def enumerate_corpus_hf(corpus: Corpus, cfg: Config) -> Tuple[List[Asset], Dict[
|
|
| 144 |
seen: Dict[str, Asset] = {}
|
| 145 |
matched_paths_by_repo: Dict[hf_catalog.RepoKey, List[str]] = {}
|
| 146 |
all_paths_by_repo: Dict[hf_catalog.RepoKey, Set[str]] = {}
|
|
|
|
| 147 |
listed = 0
|
| 148 |
|
| 149 |
for repo_key in corpus.hf_repos:
|
|
@@ -155,6 +183,7 @@ def enumerate_corpus_hf(corpus: Corpus, cfg: Config) -> Tuple[List[Asset], Dict[
|
|
| 155 |
if isinstance(entry, RepoFile)
|
| 156 |
}
|
| 157 |
all_paths_by_repo[repo_key] = set(file_entries)
|
|
|
|
| 158 |
|
| 159 |
matched: List[str] = []
|
| 160 |
for path, entry in file_entries.items():
|
|
@@ -176,6 +205,7 @@ def enumerate_corpus_hf(corpus: Corpus, cfg: Config) -> Tuple[List[Asset], Dict[
|
|
| 176 |
print(f" dropped {len(dropped)} 'removed' asset(s) from submission")
|
| 177 |
|
| 178 |
print(f" listed {listed} objects, accepted {len(seen)}")
|
|
|
|
| 179 |
asset_folders = hf_catalog.asset_folders_by_repo(corpus, matched_paths_by_repo, all_paths_by_repo)
|
| 180 |
return [seen[k] for k in sorted(seen)], asset_folders
|
| 181 |
|
|
@@ -246,6 +276,7 @@ def run(cfg: Config, dry_run: bool = False) -> int:
|
|
| 246 |
BUILD_DIR.mkdir(parents=True, exist_ok=True)
|
| 247 |
|
| 248 |
asset_folders: Dict[hf_catalog.RepoKey, List[str]] = {}
|
|
|
|
| 249 |
if corpus.storage_backend == "storage_api":
|
| 250 |
print("Enumerating HuggingFace ...")
|
| 251 |
assets, asset_folders = enumerate_corpus_hf(corpus, cfg)
|
|
|
|
| 131 |
return HfApi(token=os.environ.get("HF_TOKEN") or None)
|
| 132 |
|
| 133 |
|
| 134 |
+
PACKAGE_SIZES = BUILD_DIR / "package_sizes.json"
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def _package_sizes_group(repo_key: hf_catalog.RepoKey, file_entries: Dict) -> Dict:
|
| 138 |
+
"""One (repo, revision)'s sums for the Space's /package_sizes.json, from the listing enumerate already made:
|
| 139 |
+
the whole repo's bytes and each top-level folder's β every file, not only the matched ones (same definition
|
| 140 |
+
as pipeline/package_download/sizes.py's own listing, which this spares at Space start)."""
|
| 141 |
+
_host, repo_type, repo_id, revision = repo_key
|
| 142 |
+
whole, folders = 0, {}
|
| 143 |
+
for path, entry in file_entries.items():
|
| 144 |
+
size = int(entry.size or 0)
|
| 145 |
+
whole += size
|
| 146 |
+
if "/" in path:
|
| 147 |
+
top = path.split("/", 1)[0]
|
| 148 |
+
folders[top] = folders.get(top, 0) + size
|
| 149 |
+
return {"kind": f"{repo_type}s", "repo_id": repo_id, "revision": revision, "whole": whole,
|
| 150 |
+
"folders": folders, "files": len(file_entries)}
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def write_package_sizes(groups: List[Dict]) -> None:
|
| 154 |
+
"""`export` ships this with the run (next to hf-storage-api-catalog.yaml); the Space's sizes task loads it
|
| 155 |
+
from the restore and lists HF only for a revision it lacks."""
|
| 156 |
+
doc = {"version": 1, "generated_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), "groups": groups}
|
| 157 |
+
PACKAGE_SIZES.write_text(json.dumps(doc, separators=(",", ":"), sort_keys=True))
|
| 158 |
+
print(f" wrote {PACKAGE_SIZES.name}: {len(groups)} (repo, revision) group(s)")
|
| 159 |
+
|
| 160 |
+
|
| 161 |
def enumerate_corpus_hf(corpus: Corpus, cfg: Config) -> Tuple[List[Asset], Dict[hf_catalog.RepoKey, List[str]]]:
|
| 162 |
"""The `storage_api` backend's counterpart to `enumerate_corpus` β lists
|
| 163 |
each corpus HF repo's full file tree, applies the same include/exclude/
|
|
|
|
| 171 |
seen: Dict[str, Asset] = {}
|
| 172 |
matched_paths_by_repo: Dict[hf_catalog.RepoKey, List[str]] = {}
|
| 173 |
all_paths_by_repo: Dict[hf_catalog.RepoKey, Set[str]] = {}
|
| 174 |
+
package_sizes: List[Dict] = []
|
| 175 |
listed = 0
|
| 176 |
|
| 177 |
for repo_key in corpus.hf_repos:
|
|
|
|
| 183 |
if isinstance(entry, RepoFile)
|
| 184 |
}
|
| 185 |
all_paths_by_repo[repo_key] = set(file_entries)
|
| 186 |
+
package_sizes.append(_package_sizes_group(repo_key, file_entries))
|
| 187 |
|
| 188 |
matched: List[str] = []
|
| 189 |
for path, entry in file_entries.items():
|
|
|
|
| 205 |
print(f" dropped {len(dropped)} 'removed' asset(s) from submission")
|
| 206 |
|
| 207 |
print(f" listed {listed} objects, accepted {len(seen)}")
|
| 208 |
+
write_package_sizes(package_sizes)
|
| 209 |
asset_folders = hf_catalog.asset_folders_by_repo(corpus, matched_paths_by_repo, all_paths_by_repo)
|
| 210 |
return [seen[k] for k in sorted(seen)], asset_folders
|
| 211 |
|
|
|
|
| 276 |
BUILD_DIR.mkdir(parents=True, exist_ok=True)
|
| 277 |
|
| 278 |
asset_folders: Dict[hf_catalog.RepoKey, List[str]] = {}
|
| 279 |
+
PACKAGE_SIZES.unlink(missing_ok=True) # an earlier run's sums never ride along with this one
|
| 280 |
if corpus.storage_backend == "storage_api":
|
| 281 |
print("Enumerating HuggingFace ...")
|
| 282 |
assets, asset_folders = enumerate_corpus_hf(corpus, cfg)
|
pipeline/export.py
CHANGED
|
@@ -218,9 +218,14 @@ def export_neo4j(cfg: Config) -> Dict:
|
|
| 218 |
print(" stopping asset-graph-service ...")
|
| 219 |
_run(["docker", "compose", "-p", COMPOSE_PROJECT, *cfg.indexing_compose_files(),
|
| 220 |
"stop", "--timeout", "300", "asset-graph-service"], env=cfg.compose_env())
|
| 221 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 222 |
_run(["docker", "compose", "-p", COMPOSE_PROJECT, *cfg.indexing_compose_files(),
|
| 223 |
-
"stop", "--timeout",
|
| 224 |
|
| 225 |
print(" dumping neo4j ...")
|
| 226 |
# Runs as root (the image default), NOT as the host uid: the data volume is
|
|
@@ -276,6 +281,18 @@ def run(cfg: Config) -> int:
|
|
| 276 |
|
| 277 |
print("Exporting OpenSearch ...")
|
| 278 |
manifest["opensearch_artifacts"] = export_opensearch(cfg, manifest)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 279 |
|
| 280 |
print("\nExporting Neo4j ...")
|
| 281 |
manifest["neo4j_artifact"] = export_neo4j(cfg)
|
|
@@ -289,10 +306,14 @@ def run(cfg: Config) -> int:
|
|
| 289 |
# docs/hf-space-deployment.md Β§4 item 3 for why this is a
|
| 290 |
# frozen index-time artifact, not something restored by re-listing the
|
| 291 |
# HF dataset at Space startup.
|
| 292 |
-
|
|
|
|
|
|
|
| 293 |
src = BUILD_DIR / name
|
| 294 |
if src.exists():
|
| 295 |
shutil.copy2(src, ARTIFACTS / name)
|
|
|
|
|
|
|
| 296 |
|
| 297 |
save_manifest(manifest)
|
| 298 |
shutil.copy2(BUILD_DIR / "manifest.json", ARTIFACTS / "manifest.json")
|
|
|
|
| 218 |
print(" stopping asset-graph-service ...")
|
| 219 |
_run(["docker", "compose", "-p", COMPOSE_PROJECT, *cfg.indexing_compose_files(),
|
| 220 |
"stop", "--timeout", "300", "asset-graph-service"], env=cfg.compose_env())
|
| 221 |
+
# 300 s wasn't enough either on 2026-10-16: the full simready-assets graph
|
| 222 |
+
# (~149k nodes, ~12k property indexes) was SIGKILLed mid-checkpoint and the
|
| 223 |
+
# dump refused with "Active logical log detected". Recover by starting
|
| 224 |
+
# neo4j once (it replays the log) and rerunning export.
|
| 225 |
+
stop_timeout = cfg.get_int("NEO4J_STOP_TIMEOUT", 1800)
|
| 226 |
+
print(f" stopping neo4j (up to {stop_timeout}s for its shutdown checkpoint) ...")
|
| 227 |
_run(["docker", "compose", "-p", COMPOSE_PROJECT, *cfg.indexing_compose_files(),
|
| 228 |
+
"stop", "--timeout", str(stop_timeout), "neo4j"], env=cfg.compose_env())
|
| 229 |
|
| 230 |
print(" dumping neo4j ...")
|
| 231 |
# Runs as root (the image default), NOT as the host uid: the data volume is
|
|
|
|
| 281 |
|
| 282 |
print("Exporting OpenSearch ...")
|
| 283 |
manifest["opensearch_artifacts"] = export_opensearch(cfg, manifest)
|
| 284 |
+
from .image_overrides import check_dump # every doc its own nonzero vector, >= 98 % distinct
|
| 285 |
+
main_dump = OS_ARTIFACTS / f"{cfg.main_index}.data.json.gz"
|
| 286 |
+
if main_dump.exists() and check_dump(main_dump) != 0:
|
| 287 |
+
print(f"export: {main_dump.name} fails the vector check β not exporting a broken index")
|
| 288 |
+
return 1
|
| 289 |
+
|
| 290 |
+
# `wait` recorded image_cache_docs before `image-overrides` ran, and each override adds an image document
|
| 291 |
+
# (the package's own thumbnail document stays): 2597 assets -> 2605 image docs on 2026-10-16. scripts/smoke.sh
|
| 292 |
+
# checks the baked image against this number, so take it from what was actually exported.
|
| 293 |
+
image_cache = manifest["opensearch_artifacts"].get(cfg.image_cache_index)
|
| 294 |
+
if image_cache is not None:
|
| 295 |
+
manifest["image_cache_docs"] = image_cache["doc_count"]
|
| 296 |
|
| 297 |
print("\nExporting Neo4j ...")
|
| 298 |
manifest["neo4j_artifact"] = export_neo4j(cfg)
|
|
|
|
| 306 |
# docs/hf-space-deployment.md Β§4 item 3 for why this is a
|
| 307 |
# frozen index-time artifact, not something restored by re-listing the
|
| 308 |
# HF dataset at Space startup.
|
| 309 |
+
# package_sizes.json (storage_api too): the Space's /package_sizes.json sums, from enumerate's own listing.
|
| 310 |
+
for name in ("corpus.json", "index-report.json", "assets.jsonl", "hf-storage-api-catalog.yaml",
|
| 311 |
+
"package_sizes.json"):
|
| 312 |
src = BUILD_DIR / name
|
| 313 |
if src.exists():
|
| 314 |
shutil.copy2(src, ARTIFACTS / name)
|
| 315 |
+
else:
|
| 316 |
+
(ARTIFACTS / name).unlink(missing_ok=True) # .build/artifacts/ outlives runs; never ship an older one's
|
| 317 |
|
| 318 |
save_manifest(manifest)
|
| 319 |
shutil.copy2(BUILD_DIR / "manifest.json", ARTIFACTS / "manifest.json")
|
pipeline/hf_space.py
CHANGED
|
@@ -51,7 +51,7 @@ from typing import Dict, List
|
|
| 51 |
from huggingface_hub import HfApi, SpaceHardware
|
| 52 |
from huggingface_hub.errors import HfHubHTTPError
|
| 53 |
|
| 54 |
-
from .artifact_store import space_artifact_secrets
|
| 55 |
from .bake import DOCKER_DIR, hf_space_build_args, hf_space_gpu_build_args, image_tags
|
| 56 |
from .config import BUILD_DIR, HF_SPACE_GPU_HARDWARE_DEFAULT, REPO_ROOT, Config
|
| 57 |
from .corpus import Corpus
|
|
@@ -225,6 +225,22 @@ def stage_repo(dest: Path, marker: str, readme: str, dockerfile_name: str = "Doc
|
|
| 225 |
shutil.copytree(REPO_ROOT / "services" / "explorer" / "dist", dest / "services" / "explorer" / "dist")
|
| 226 |
|
| 227 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 228 |
def run(cfg: Config) -> int:
|
| 229 |
manifest = load_manifest()
|
| 230 |
if not manifest.get("hf_space_smoke_passed"):
|
|
@@ -238,6 +254,8 @@ def run(cfg: Config) -> int:
|
|
| 238 |
if "hf_space" not in image_tags(cfg, manifest):
|
| 239 |
print("No hf_space image for this run (SIGLIP2_BACKEND != nvcf) β nothing to deploy.")
|
| 240 |
return 1
|
|
|
|
|
|
|
| 241 |
|
| 242 |
repo_id = cfg.require("HF_SPACE_REPO_ID") # e.g. "nvidia/simready-central"
|
| 243 |
title = cfg.get("HF_SPACE_TITLE", repo_id.split("/", 1)[-1])
|
|
@@ -329,6 +347,8 @@ def run_gpu(cfg: Config) -> int:
|
|
| 329 |
if "hf_space_gpu" not in image_tags(cfg, manifest):
|
| 330 |
print("No hf_space_gpu image for this run (HF_SPACE_BUILD_GPU_VARIANT != true) β nothing to deploy.")
|
| 331 |
return 1
|
|
|
|
|
|
|
| 332 |
|
| 333 |
repo_id = cfg.require("HF_SPACE_REPO_ID_GPU") # e.g. "nvidia/simready-central-gpu"
|
| 334 |
title = cfg.get("HF_SPACE_TITLE_GPU", repo_id.split("/", 1)[-1])
|
|
|
|
| 51 |
from huggingface_hub import HfApi, SpaceHardware
|
| 52 |
from huggingface_hub.errors import HfHubHTTPError
|
| 53 |
|
| 54 |
+
from .artifact_store import bucket_base, list_objects, space_artifact_secrets
|
| 55 |
from .bake import DOCKER_DIR, hf_space_build_args, hf_space_gpu_build_args, image_tags
|
| 56 |
from .config import BUILD_DIR, HF_SPACE_GPU_HARDWARE_DEFAULT, REPO_ROOT, Config
|
| 57 |
from .corpus import Corpus
|
|
|
|
| 225 |
shutil.copytree(REPO_ROOT / "services" / "explorer" / "dist", dest / "services" / "explorer" / "dist")
|
| 226 |
|
| 227 |
|
| 228 |
+
def hierarchy_missing(cfg: Config, manifest: Dict) -> bool:
|
| 229 |
+
"""True when the run has no published category tree. The Space serves the run's
|
| 230 |
+
hierarchy/category_hierarchy.json as /wikidata_hierarchy.json (the Explorer's Category tree), so a run without
|
| 231 |
+
one deploys a Space with no category browsing. `all --stop verify` skips the `hierarchy` stage; run it with
|
| 232 |
+
`--run-id <run>` before deploying."""
|
| 233 |
+
prefix = f"{bucket_base(cfg)}/{manifest['corpus_name']}/{manifest['run_id']}/hierarchy"
|
| 234 |
+
if any(rel == "category_hierarchy.json" for rel, _ in list_objects(cfg, prefix)):
|
| 235 |
+
return False
|
| 236 |
+
print(
|
| 237 |
+
f"Run {manifest['run_id']} has no {prefix}/category_hierarchy.json β the `hierarchy` stage is required "
|
| 238 |
+
f"before deploying. Run: python -m pipeline.run_pipeline hierarchy --env-file ... "
|
| 239 |
+
f"--run-id {manifest['run_id']} --new-only --api-url <a running stack>"
|
| 240 |
+
)
|
| 241 |
+
return True
|
| 242 |
+
|
| 243 |
+
|
| 244 |
def run(cfg: Config) -> int:
|
| 245 |
manifest = load_manifest()
|
| 246 |
if not manifest.get("hf_space_smoke_passed"):
|
|
|
|
| 254 |
if "hf_space" not in image_tags(cfg, manifest):
|
| 255 |
print("No hf_space image for this run (SIGLIP2_BACKEND != nvcf) β nothing to deploy.")
|
| 256 |
return 1
|
| 257 |
+
if hierarchy_missing(cfg, manifest):
|
| 258 |
+
return 1
|
| 259 |
|
| 260 |
repo_id = cfg.require("HF_SPACE_REPO_ID") # e.g. "nvidia/simready-central"
|
| 261 |
title = cfg.get("HF_SPACE_TITLE", repo_id.split("/", 1)[-1])
|
|
|
|
| 347 |
if "hf_space_gpu" not in image_tags(cfg, manifest):
|
| 348 |
print("No hf_space_gpu image for this run (HF_SPACE_BUILD_GPU_VARIANT != true) β nothing to deploy.")
|
| 349 |
return 1
|
| 350 |
+
if hierarchy_missing(cfg, manifest):
|
| 351 |
+
return 1
|
| 352 |
|
| 353 |
repo_id = cfg.require("HF_SPACE_REPO_ID_GPU") # e.g. "nvidia/simready-central-gpu"
|
| 354 |
title = cfg.get("HF_SPACE_TITLE_GPU", repo_id.split("/", 1)[-1])
|
pipeline/hierarchy/run.py
CHANGED
|
@@ -250,8 +250,11 @@ def run(cfg: Config, *, api_url: Optional[str] = None, replace_all: bool = False
|
|
| 250 |
categories.load_vendors(), categories.load_pins())
|
| 251 |
if unplaced:
|
| 252 |
print(f"WARNING: {len(unplaced)} {categories.PROPERTY} value(s) without a place in the tree: {unplaced}")
|
|
|
|
|
|
|
| 253 |
else:
|
| 254 |
print(f"WARNING: no {categories.REPORT} (the `categories` stage did not run): the tree keeps {qprop}")
|
|
|
|
| 255 |
(out_dir / "category_hierarchy.json").write_text(json.dumps(doc, indent=1, ensure_ascii=False, sort_keys=True) + "\n")
|
| 256 |
cache_doc = {"corpus": corpus, "generated_at": now, "qcode_property": qprop, "metrics": metrics, "placements": {
|
| 257 |
q: {"path": p.path, "label": p.label, "confidence": round(p.confidence, 3), "reason": p.reason,
|
|
|
|
| 250 |
categories.load_vendors(), categories.load_pins())
|
| 251 |
if unplaced:
|
| 252 |
print(f"WARNING: {len(unplaced)} {categories.PROPERTY} value(s) without a place in the tree: {unplaced}")
|
| 253 |
+
# Package folder -> category folder for installers, from the same tree (served as /install_categories.json).
|
| 254 |
+
categories.write_install_list(out_dir, doc, run_id=target.rsplit("/", 1)[-1] if target else "", corpus=corpus)
|
| 255 |
else:
|
| 256 |
print(f"WARNING: no {categories.REPORT} (the `categories` stage did not run): the tree keeps {qprop}")
|
| 257 |
+
(out_dir / categories.INSTALL_FILE).unlink(missing_ok=True)
|
| 258 |
(out_dir / "category_hierarchy.json").write_text(json.dumps(doc, indent=1, ensure_ascii=False, sort_keys=True) + "\n")
|
| 259 |
cache_doc = {"corpus": corpus, "generated_at": now, "qcode_property": qprop, "metrics": metrics, "placements": {
|
| 260 |
q: {"path": p.path, "label": p.label, "confidence": round(p.confidence, 3), "reason": p.reason,
|
pipeline/hierarchy/store.py
CHANGED
|
@@ -2,6 +2,7 @@
|
|
| 2 |
|
| 3 |
<ARTIFACT_S3_URI>/<corpus>/<run_id>/hierarchy/
|
| 4 |
category_hierarchy.json the Explorer's tree (the Space restores it with the run)
|
|
|
|
| 5 |
placements.json decision cache: reused by the next run
|
| 6 |
folders_added.txt folders the agent created so far (base = repo file + these)
|
| 7 |
report.md, report.html, misfiled_assets.json for review
|
|
|
|
| 2 |
|
| 3 |
<ARTIFACT_S3_URI>/<corpus>/<run_id>/hierarchy/
|
| 4 |
category_hierarchy.json the Explorer's tree (the Space restores it with the run)
|
| 5 |
+
install_categories.json package folder -> category folder (pipeline/categories.py install_list)
|
| 6 |
placements.json decision cache: reused by the next run
|
| 7 |
folders_added.txt folders the agent created so far (base = repo file + these)
|
| 8 |
report.md, report.html, misfiled_assets.json for review
|
pipeline/image_overrides.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Stage `image-overrides` β embed, thumbnail and caption chosen assets from OUR image instead of the package's.
|
| 2 |
+
|
| 3 |
+
A partner package's own `.thumbs/256x256/` thumbnail can be a poor picture of it (a flat render on white, a cropped
|
| 4 |
+
part); its SigLIP2 embedding then drifts into unrelated searches. config/image_overrides/overrides.yaml names, per
|
| 5 |
+
asset (its hf:// address in the corpus), an image in that folder β SimReady Central's RTX still or spin start frame.
|
| 6 |
+
After `wait`, this stage runs usdsearch's OWN plugins on that image, inside the usdsearch image (same SigLIP2 client,
|
| 7 |
+
same VLM metadata generation and field schema, same OpenSearch writer as the workers), for each listed asset:
|
| 8 |
+
|
| 9 |
+
* thumbnail_to_embedding: the embedding and the stored thumbnail (what /images serves) are replaced;
|
| 10 |
+
* thumbnail_to_vision_metadata: the VLM metadata (caption, tags, ...) is written β only when IMAGE_OVERRIDES_VLM is
|
| 11 |
+
on, with USDSEARCH_LLM_BASE_URL / USDSEARCH_LLM_API_KEY / USDSEARCH_VISION_METADATA_MODEL from the environment.
|
| 12 |
+
|
| 13 |
+
Every call has a timeout and retries (IMAGE_OVERRIDES_TIMEOUT_S, IMAGE_OVERRIDES_TRIES); any asset left without its
|
| 14 |
+
embedding, or without its metadata when VLM is on, fails the stage. A wide image is padded to a square first (the
|
| 15 |
+
SigLIP2 preprocessing squashes to 384x384). The record goes to .build/image-overrides.json.
|
| 16 |
+
|
| 17 |
+
python -m pipeline.run_pipeline image-overrides --env-file ... [--dry-run]
|
| 18 |
+
|
| 19 |
+
An entry may also name `vlm_image`: the picture the VLM describes instead of `image` (e.g. an appliance shown open).
|
| 20 |
+
REEMBED_FROM_THUMBNAILS=true additionally recomputes every OTHER asset's embedding from its own `.thumbs` thumbnail
|
| 21 |
+
through the plugin's own loader (batched: REEMBED_BATCH, REEMBED_CONCURRENCY) and then checks that every document
|
| 22 |
+
carries its own vector (none zero, at least 98 % distinct) β the repair for vectors a restore left unusable.
|
| 23 |
+
"""
|
| 24 |
+
|
| 25 |
+
from __future__ import annotations
|
| 26 |
+
|
| 27 |
+
import json
|
| 28 |
+
import os
|
| 29 |
+
import subprocess
|
| 30 |
+
import sys
|
| 31 |
+
from pathlib import Path
|
| 32 |
+
from typing import Dict, List
|
| 33 |
+
|
| 34 |
+
import yaml
|
| 35 |
+
|
| 36 |
+
from .config import BUILD_DIR, REPO_ROOT, Config
|
| 37 |
+
|
| 38 |
+
OVERRIDES_DIR = REPO_ROOT / "config" / "image_overrides"
|
| 39 |
+
SPEC_FILE = OVERRIDES_DIR / "overrides.yaml"
|
| 40 |
+
REPORT = BUILD_DIR / "image-overrides.json"
|
| 41 |
+
WORKER = Path(__file__).with_name("image_overrides_worker.py")
|
| 42 |
+
COMPOSE_PROJECT = "simready-indexing"
|
| 43 |
+
SERVICE = "omni-writer" # has the OpenSearch writer's settings; the SigLIP2/LLM env is added per run
|
| 44 |
+
IMAGE_EXTS = (".jpg", ".jpeg", ".png", ".webp")
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def load_spec(path: Path = SPEC_FILE, includes: List[str] | None = None) -> Dict[str, Dict]:
|
| 48 |
+
"""{asset address: {image, source}} β every image present in the folder, every asset in the corpus (when given)."""
|
| 49 |
+
if not path.exists():
|
| 50 |
+
return {}
|
| 51 |
+
raw = yaml.safe_load(path.read_text()) or {}
|
| 52 |
+
out: Dict[str, Dict] = {}
|
| 53 |
+
problems: List[str] = []
|
| 54 |
+
include_set = set(includes) if includes is not None else None
|
| 55 |
+
for key, rec in raw.items():
|
| 56 |
+
rec = rec if isinstance(rec, dict) else {"image": rec}
|
| 57 |
+
image = str(rec.get("image") or "")
|
| 58 |
+
if not key.startswith("hf://"):
|
| 59 |
+
problems.append(f"{key}: not an hf:// asset address")
|
| 60 |
+
if not image or "/" in image or not image.lower().endswith(IMAGE_EXTS):
|
| 61 |
+
problems.append(f"{key}: image {image!r} must be a file name in {OVERRIDES_DIR.name}/ ({', '.join(IMAGE_EXTS)})")
|
| 62 |
+
elif not (path.parent / image).is_file():
|
| 63 |
+
problems.append(f"{key}: {image} is missing")
|
| 64 |
+
if include_set is not None and key not in include_set:
|
| 65 |
+
problems.append(f"{key}: not in the corpus")
|
| 66 |
+
vlm_image = str(rec.get("vlm_image") or "")
|
| 67 |
+
if vlm_image and ("/" in vlm_image or not vlm_image.lower().endswith(IMAGE_EXTS) or not (path.parent / vlm_image).is_file()):
|
| 68 |
+
problems.append(f"{key}: vlm_image {vlm_image!r} must be an image file in {OVERRIDES_DIR.name}/")
|
| 69 |
+
out[key] = {"image": image, "source": str(rec.get("source") or "")}
|
| 70 |
+
if vlm_image:
|
| 71 |
+
out[key]["vlm_image"] = vlm_image
|
| 72 |
+
if problems:
|
| 73 |
+
raise SystemExit("image overrides:\n " + "\n ".join(problems))
|
| 74 |
+
return out
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def vlm_enabled() -> bool:
|
| 78 |
+
return os.environ.get("IMAGE_OVERRIDES_VLM", "false").strip().lower() in ("1", "true", "yes", "on")
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def run(cfg: Config, dry_run: bool = False) -> int:
|
| 82 |
+
corpus_path = BUILD_DIR / "corpus.json"
|
| 83 |
+
includes = json.loads(corpus_path.read_text()).get("include") if corpus_path.exists() else None
|
| 84 |
+
spec = load_spec(includes=includes)
|
| 85 |
+
if not spec:
|
| 86 |
+
print(f"no image overrides ({SPEC_FILE.relative_to(REPO_ROOT)} empty or absent) β nothing to do")
|
| 87 |
+
return 0
|
| 88 |
+
vlm = vlm_enabled()
|
| 89 |
+
if vlm and not os.environ.get("USDSEARCH_LLM_API_KEY", "").strip():
|
| 90 |
+
print("IMAGE_OVERRIDES_VLM is on but USDSEARCH_LLM_API_KEY is not set in the environment")
|
| 91 |
+
return 1
|
| 92 |
+
print(f"{len(spec)} image override(s); VLM metadata {'on (' + os.environ.get('USDSEARCH_VISION_METADATA_MODEL', 'default model') + ')' if vlm else 'off'}")
|
| 93 |
+
for key, rec in spec.items():
|
| 94 |
+
print(f" {rec['image']:<44} {key}")
|
| 95 |
+
if dry_run:
|
| 96 |
+
print("dry run: nothing written")
|
| 97 |
+
return 0
|
| 98 |
+
|
| 99 |
+
BUILD_DIR.mkdir(parents=True, exist_ok=True)
|
| 100 |
+
spec_path = BUILD_DIR / "image-overrides-spec.json"
|
| 101 |
+
reembed = []
|
| 102 |
+
if os.environ.get("REEMBED_FROM_THUMBNAILS", "").strip().lower() in ("1", "true", "yes", "on"):
|
| 103 |
+
# Repair: every other asset's embedding is recomputed from its own .thumbs thumbnail (the plugin's own loader).
|
| 104 |
+
reembed = sorted(k for k in (includes or []) if k not in spec)
|
| 105 |
+
print(f"re-embedding {len(reembed)} other asset(s) from their own thumbnails")
|
| 106 |
+
spec_path.write_text(json.dumps({"index": cfg.main_index, "vlm": vlm, "assets": spec, "reembed": reembed}, indent=1) + "\n")
|
| 107 |
+
passthrough = [
|
| 108 |
+
"SIGLIP2_TRITON_SERVER_URL", "SIGLIP2_TRITON_SERVER_SSL", "SIGLIP2_TRITON_SERVER_AUTH_TOKEN",
|
| 109 |
+
"SIGLIP2_TRITON_SERVER_HEADERS", "USDSEARCH_LLM_BASE_URL", "USDSEARCH_LLM_API_KEY",
|
| 110 |
+
"USDSEARCH_VISION_METADATA_MODEL", "IMAGE_OVERRIDES_TIMEOUT_S", "IMAGE_OVERRIDES_TRIES",
|
| 111 |
+
"REEMBED_BATCH", "REEMBED_CONCURRENCY",
|
| 112 |
+
]
|
| 113 |
+
env = cfg.compose_env()
|
| 114 |
+
cmd = ["docker", "compose", "-p", COMPOSE_PROJECT, *cfg.indexing_compose_files(), "run", "--rm", "--no-deps", "-T",
|
| 115 |
+
"-v", f"{WORKER}:/ops/image_overrides_worker.py:ro",
|
| 116 |
+
"-v", f"{OVERRIDES_DIR}:/overrides:ro",
|
| 117 |
+
"-v", f"{spec_path}:/ops/spec.json:ro",
|
| 118 |
+
"-v", f"{REPO_ROOT / 'config' / 'vlm_metadata_fields.yaml'}:/app/vlm_metadata_fields.yaml:ro",
|
| 119 |
+
"-e", "USDSEARCH_VISION_METADATA_YAML_FILEPATH=/app/vlm_metadata_fields.yaml",
|
| 120 |
+
"-e", f"OPENSEARCH_URL_INTERNAL=http://opensearch:9200"]
|
| 121 |
+
for var in passthrough:
|
| 122 |
+
if env.get(var):
|
| 123 |
+
cmd += ["-e", var] # value from the compose CLI's environment, never on the command line
|
| 124 |
+
cmd += [SERVICE, "python", "/ops/image_overrides_worker.py", "/ops/spec.json"]
|
| 125 |
+
print("$ docker compose ... run --rm " + SERVICE + " python /ops/image_overrides_worker.py")
|
| 126 |
+
proc = subprocess.run(cmd, env=env, capture_output=True, text=True)
|
| 127 |
+
sys.stdout.write(proc.stderr[-20000:] if proc.returncode else "")
|
| 128 |
+
report = None
|
| 129 |
+
for line in proc.stdout.splitlines():
|
| 130 |
+
if line.startswith("IMAGE_OVERRIDES_REPORT "):
|
| 131 |
+
report = json.loads(line[len("IMAGE_OVERRIDES_REPORT "):])
|
| 132 |
+
else:
|
| 133 |
+
print(line)
|
| 134 |
+
if report is None:
|
| 135 |
+
print(f"the worker wrote no report (exit {proc.returncode})")
|
| 136 |
+
return 1
|
| 137 |
+
REPORT.write_text(json.dumps(report, indent=1) + "\n")
|
| 138 |
+
failed = [k for k, r in report["assets"].items() if not r.get("ok")]
|
| 139 |
+
print(f"image overrides: {len(report['assets']) - len(failed)} ok, {len(failed)} failed")
|
| 140 |
+
if failed or proc.returncode:
|
| 141 |
+
for k in failed:
|
| 142 |
+
print(f" FAILED {k}: {report['assets'][k].get('error')}")
|
| 143 |
+
return 1
|
| 144 |
+
return 0
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def check_dump(path: Path, min_distinct: float = 0.98) -> int:
|
| 148 |
+
"""Gate on an exported OpenSearch dump (elasticdump layout, .data.json[.gz]): every document carries a nonzero
|
| 149 |
+
siglip2 vector and at least `min_distinct` of them are distinct. Run 20261012-9d9f42a's dump failed this: one
|
| 150 |
+
vector on 1,399 of 1,404 documents."""
|
| 151 |
+
import gzip
|
| 152 |
+
import hashlib
|
| 153 |
+
|
| 154 |
+
def first_vector(o):
|
| 155 |
+
if isinstance(o, list) and o and all(isinstance(x, (int, float)) for x in o[:4]) and len(o) >= 64:
|
| 156 |
+
return o
|
| 157 |
+
items = o.values() if isinstance(o, dict) else (o if isinstance(o, list) else [])
|
| 158 |
+
for v in items:
|
| 159 |
+
found = first_vector(v)
|
| 160 |
+
if found is not None:
|
| 161 |
+
return found
|
| 162 |
+
return None
|
| 163 |
+
|
| 164 |
+
opener = gzip.open if str(path).endswith(".gz") else open
|
| 165 |
+
docs = missing = zero = 0
|
| 166 |
+
counts: Dict[str, int] = {}
|
| 167 |
+
with opener(path, "rt") as fh:
|
| 168 |
+
for line in fh:
|
| 169 |
+
if not line.strip():
|
| 170 |
+
continue
|
| 171 |
+
src = json.loads(line).get("_source") or {}
|
| 172 |
+
docs += 1
|
| 173 |
+
v = first_vector(src.get("siglip2-embedding"))
|
| 174 |
+
if v is None:
|
| 175 |
+
missing += 1
|
| 176 |
+
continue
|
| 177 |
+
if sum(x * x for x in v) < 1e-12:
|
| 178 |
+
zero += 1
|
| 179 |
+
h = hashlib.sha1(json.dumps([round(x, 5) for x in v]).encode()).hexdigest()
|
| 180 |
+
counts[h] = counts.get(h, 0) + 1
|
| 181 |
+
distinct = len(counts)
|
| 182 |
+
largest = max(counts.values(), default=0)
|
| 183 |
+
print(f"{path.name}: {docs} docs, {missing} without a vector, {zero} zero, {distinct} distinct, largest shared group {largest}")
|
| 184 |
+
return 0 if docs and not missing and not zero and distinct >= min_distinct * docs else 1
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
if __name__ == "__main__":
|
| 188 |
+
if len(sys.argv) == 3 and sys.argv[1] == "check-dump":
|
| 189 |
+
sys.exit(check_dump(Path(sys.argv[2])))
|
| 190 |
+
sys.exit("usage: python -m pipeline.image_overrides check-dump <...data.json[.gz]>")
|
pipeline/image_overrides_worker.py
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Runs INSIDE the usdsearch image (pipeline/image_overrides.py starts it with `docker compose run`): usdsearch's own
|
| 2 |
+
thumbnail_to_embedding and thumbnail_to_vision_metadata plugins on our image for each listed asset, written with the
|
| 3 |
+
plugins' own add_metadata through the same storage client the omni-writer uses. Prints one line
|
| 4 |
+
`IMAGE_OVERRIDES_REPORT {json}` at the end. Stdlib + what the usdsearch image ships (PIL, plugins, storage).
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import asyncio
|
| 8 |
+
import hashlib
|
| 9 |
+
import json
|
| 10 |
+
import os
|
| 11 |
+
import sys
|
| 12 |
+
import time
|
| 13 |
+
import traceback
|
| 14 |
+
import urllib.request
|
| 15 |
+
|
| 16 |
+
from PIL import Image
|
| 17 |
+
|
| 18 |
+
TIMEOUT_S = float(os.environ.get("IMAGE_OVERRIDES_TIMEOUT_S") or 180)
|
| 19 |
+
TRIES = int(os.environ.get("IMAGE_OVERRIDES_TRIES") or 3)
|
| 20 |
+
OS_URL = os.environ.get("OPENSEARCH_URL_INTERNAL", "http://opensearch:9200").rstrip("/")
|
| 21 |
+
THUMB_PX = 256 # the size every other asset's thumbnail is embedded and stored at (.thumbs/256x256)
|
| 22 |
+
VLM_PX = 512 # the VLM plugin's own vlm_image_max_px default
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def square(img: Image.Image) -> Image.Image:
|
| 26 |
+
"""Pad a wide or tall image to a square with its own border colour (the SigLIP2 preprocessing squashes to a
|
| 27 |
+
square, which would distort it)."""
|
| 28 |
+
img = img.convert("RGB")
|
| 29 |
+
w, h = img.size
|
| 30 |
+
if w == h:
|
| 31 |
+
return img
|
| 32 |
+
side = max(w, h)
|
| 33 |
+
px = img.load()
|
| 34 |
+
border = [px[x, 0] for x in range(w)] + [px[x, h - 1] for x in range(w)] + [px[0, y] for y in range(h)] + [px[w - 1, y] for y in range(h)]
|
| 35 |
+
fill = tuple(sum(c[i] for c in border) // len(border) for i in range(3))
|
| 36 |
+
out = Image.new("RGB", (side, side), fill)
|
| 37 |
+
out.paste(img, ((side - w) // 2, (side - h) // 2))
|
| 38 |
+
return out
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def os_doc(index: str, key: str) -> dict:
|
| 42 |
+
body = json.dumps({"size": 2, "query": {"term": {"base_key": key}}}).encode()
|
| 43 |
+
req = urllib.request.Request(f"{OS_URL}/{index}/_search", data=body, headers={"content-type": "application/json"})
|
| 44 |
+
hits = json.load(urllib.request.urlopen(req, timeout=60))["hits"]["hits"]
|
| 45 |
+
if len(hits) != 1:
|
| 46 |
+
raise RuntimeError(f"{len(hits)} documents for {key}")
|
| 47 |
+
return hits[0]["_source"]
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def digests(src: dict) -> dict:
|
| 51 |
+
return {k: hashlib.sha256(json.dumps(v, sort_keys=True, default=str).encode()).hexdigest()[:12] for k, v in src.items()}
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def result_content(results: dict, plugin_name: str):
|
| 55 |
+
r = results.get(0)
|
| 56 |
+
if r is None:
|
| 57 |
+
raise RuntimeError(f"{plugin_name}: no result")
|
| 58 |
+
content = (r.get("search_backend_content") or {}).get(plugin_name)
|
| 59 |
+
if not content:
|
| 60 |
+
raise RuntimeError(f"{plugin_name}: empty content ({r.get('asset_status')})")
|
| 61 |
+
if "error" in content:
|
| 62 |
+
raise RuntimeError(f"{plugin_name}: {content['error']}")
|
| 63 |
+
return content
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
async def with_retries(what: str, fn):
|
| 67 |
+
last = None
|
| 68 |
+
for attempt in range(1, TRIES + 1):
|
| 69 |
+
try:
|
| 70 |
+
return await asyncio.wait_for(fn(), timeout=TIMEOUT_S)
|
| 71 |
+
except Exception as exc: # noqa: BLE001 β retried, then reported
|
| 72 |
+
last = f"{type(exc).__name__}: {exc}"
|
| 73 |
+
print(f" {what}: attempt {attempt}/{TRIES} failed: {last}", flush=True)
|
| 74 |
+
await asyncio.sleep(5 * attempt)
|
| 75 |
+
raise RuntimeError(f"{what}: gave up after {TRIES} attempts: {last}")
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def thumbnail_url(key: str) -> str:
|
| 79 |
+
"""The asset's own .thumbs/256x256 thumbnail at its pinned revision β the file the thumbnail_to_embedding worker
|
| 80 |
+
embeds: hf://<host>/datasets/<o>/<n>/resolve/<rev>/<dir>/<file> -> https://<host>/datasets/.../<dir>/.thumbs/256x256/<file>.png"""
|
| 81 |
+
if not key.startswith("hf://"):
|
| 82 |
+
raise ValueError(f"not an hf:// address: {key}")
|
| 83 |
+
folder, name = key[len("hf://"):].rsplit("/", 1)
|
| 84 |
+
return f"https://{folder}/.thumbs/256x256/{name}.png"
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def fetch_thumbnail(key: str) -> Image.Image:
|
| 88 |
+
import io
|
| 89 |
+
last = None
|
| 90 |
+
for attempt in range(1, 6):
|
| 91 |
+
try:
|
| 92 |
+
req = urllib.request.Request(thumbnail_url(key), headers={"User-Agent": "simready-central-indexing/reembed"})
|
| 93 |
+
with urllib.request.urlopen(req, timeout=60) as f:
|
| 94 |
+
img = Image.open(io.BytesIO(f.read()))
|
| 95 |
+
img.load()
|
| 96 |
+
return img.convert("RGB")
|
| 97 |
+
except urllib.error.HTTPError as exc:
|
| 98 |
+
last = exc
|
| 99 |
+
if exc.code not in (429, 500, 502, 503, 504):
|
| 100 |
+
raise
|
| 101 |
+
except Exception as exc: # noqa: BLE001
|
| 102 |
+
last = exc
|
| 103 |
+
time.sleep(2 * attempt)
|
| 104 |
+
raise RuntimeError(f"thumbnail {thumbnail_url(key)}: {last}")
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def served_thumbnail(index: str, key: str) -> Image.Image:
|
| 108 |
+
"""The thumbnail the Space serves for `key` (/images): the image-cache document the doc's siglip2-embedding
|
| 109 |
+
entry points at (its `image` id), decoded."""
|
| 110 |
+
import base64
|
| 111 |
+
import io
|
| 112 |
+
src = os_doc(index, key)
|
| 113 |
+
entries = src.get("siglip2-embedding") or []
|
| 114 |
+
image_id = entries[0].get("image") if entries and isinstance(entries[0], dict) else None
|
| 115 |
+
if not image_id:
|
| 116 |
+
raise RuntimeError("no image id on the document")
|
| 117 |
+
cache_index = index.replace("-ver5.0", "-ver4.0-image-cache")
|
| 118 |
+
doc = json.load(urllib.request.urlopen(f"{OS_URL}/{cache_index}/_doc/{image_id}", timeout=60))["_source"]
|
| 119 |
+
b64 = next(iter(doc.values()))
|
| 120 |
+
img = Image.open(io.BytesIO(base64.b64decode(b64)))
|
| 121 |
+
img.load()
|
| 122 |
+
return img.convert("RGB")
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def same_picture(a: Image.Image, b: Image.Image) -> float:
|
| 126 |
+
"""Mean absolute difference (0-255) of two pictures at 64x64 greyscale: JPEG re-encoding stays well under 10."""
|
| 127 |
+
pa = list(a.convert("L").resize((64, 64), Image.BILINEAR).getdata())
|
| 128 |
+
pb = list(b.convert("L").resize((64, 64), Image.BILINEAR).getdata())
|
| 129 |
+
return sum(abs(x - y) for x, y in zip(pa, pb)) / len(pa)
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def check_sources(index: str, keys: list, report: dict, sample: int = 20, max_diff: float = 12.0) -> bool:
|
| 133 |
+
"""Before re-embedding: on a sample, the fetched .thumbs file must be the picture the Space serves."""
|
| 134 |
+
step = max(1, len(keys) // sample)
|
| 135 |
+
rows, bad = [], 0
|
| 136 |
+
for key in keys[::step][:sample]:
|
| 137 |
+
try:
|
| 138 |
+
fetched, served = fetch_thumbnail(key), served_thumbnail(index, key)
|
| 139 |
+
d = round(same_picture(fetched, served), 2)
|
| 140 |
+
rows.append({"key": key, "fetched": list(fetched.size), "served": list(served.size), "diff": d})
|
| 141 |
+
bad += d > max_diff
|
| 142 |
+
except Exception as exc: # noqa: BLE001
|
| 143 |
+
rows.append({"key": key, "error": f"{type(exc).__name__}: {exc}"})
|
| 144 |
+
bad += 1
|
| 145 |
+
report["source_check"] = {"sample": len(rows), "mismatched": bad, "max_diff": max_diff, "rows": rows}
|
| 146 |
+
print(f" source check: {len(rows) - bad} of {len(rows)} fetched thumbnails match the served ones "
|
| 147 |
+
f"(diffs {[r.get('diff', r.get('error')) for r in rows]})", flush=True)
|
| 148 |
+
return bad <= 2
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
async def reembed(keys, emb, storage, report):
|
| 152 |
+
"""thumbnail_to_embedding for each asset from its OWN .thumbs/256x256 thumbnail (fetched from its pinned HF
|
| 153 |
+
revision), batched; the embedding and stored thumbnail replaced through the plugin's add_metadata."""
|
| 154 |
+
|
| 155 |
+
batch_size = int(os.environ.get("REEMBED_BATCH") or 16)
|
| 156 |
+
concurrency = int(os.environ.get("REEMBED_CONCURRENCY") or 4)
|
| 157 |
+
sem = asyncio.Semaphore(concurrency)
|
| 158 |
+
done = {"ok": 0, "failed": []}
|
| 159 |
+
|
| 160 |
+
async def one_batch(chunk):
|
| 161 |
+
async with sem:
|
| 162 |
+
loaded = []
|
| 163 |
+
for key in chunk:
|
| 164 |
+
try:
|
| 165 |
+
img = await asyncio.wait_for(asyncio.to_thread(fetch_thumbnail, key), timeout=TIMEOUT_S)
|
| 166 |
+
loaded.append(emb.load_data(omni_path=key, data=[img]))
|
| 167 |
+
except Exception as exc: # noqa: BLE001
|
| 168 |
+
done["failed"].append({"key": key, "error": f"thumbnail: {type(exc).__name__}: {exc}"})
|
| 169 |
+
if not loaded:
|
| 170 |
+
return
|
| 171 |
+
|
| 172 |
+
async def embed():
|
| 173 |
+
batch, idx, errors = await emb.preprocess(data=loaded)
|
| 174 |
+
results = await emb.process_valid_items(batch_data=batch, indices=idx, sample_ids=list(range(len(loaded))))
|
| 175 |
+
return batch, idx, errors, results
|
| 176 |
+
|
| 177 |
+
try:
|
| 178 |
+
batch, idx, errors, results = await with_retries(f"embedding batch of {len(loaded)}", embed)
|
| 179 |
+
except Exception as exc: # noqa: BLE001
|
| 180 |
+
done["failed"] += [{"key": d["omni_path"], "error": str(exc)} for d in loaded]
|
| 181 |
+
return
|
| 182 |
+
for i, err in errors.items():
|
| 183 |
+
done["failed"].append({"key": loaded[i]["omni_path"], "error": f"preprocess: {err}"})
|
| 184 |
+
for i in idx:
|
| 185 |
+
key = loaded[i]["omni_path"]
|
| 186 |
+
try:
|
| 187 |
+
content = result_content({0: results[i]}, "thumbnail_to_embedding")
|
| 188 |
+
await emb.add_metadata(path=key, content=content, storage_client=storage)
|
| 189 |
+
done["ok"] += 1
|
| 190 |
+
except Exception as exc: # noqa: BLE001
|
| 191 |
+
done["failed"].append({"key": key, "error": f"write: {type(exc).__name__}: {exc}"})
|
| 192 |
+
print(f" re-embedded {done['ok']} so far, {len(done['failed'])} failed", flush=True)
|
| 193 |
+
|
| 194 |
+
chunks = [keys[i:i + batch_size] for i in range(0, len(keys), batch_size)]
|
| 195 |
+
await asyncio.gather(*(one_batch(c) for c in chunks))
|
| 196 |
+
report["reembed"] = {"requested": len(keys), "ok": done["ok"], "failed": done["failed"][:50], "failed_count": len(done["failed"]),
|
| 197 |
+
"batch": batch_size, "concurrency": concurrency}
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
async def main(spec_path: str) -> int:
|
| 201 |
+
from plugins.thumbnail_to_embedding import ThumbnailToEmbedding
|
| 202 |
+
from plugins.thumbnail_to_vision_metadata import ThumbnailToVisionMetadata, ThumbnailToVisionMetadataConfig
|
| 203 |
+
from search_utils.storage_client.config import StorageConfig
|
| 204 |
+
from storage.src.client import NGSearchStorageClient
|
| 205 |
+
|
| 206 |
+
spec = json.load(open(spec_path))
|
| 207 |
+
index, vlm_on = spec["index"], spec["vlm"]
|
| 208 |
+
storage = await NGSearchStorageClient.get_service(storage_config=StorageConfig(), storage_client_config=None,
|
| 209 |
+
search_backend_config=None)
|
| 210 |
+
emb = ThumbnailToEmbedding()
|
| 211 |
+
vlm = ThumbnailToVisionMetadata(ThumbnailToVisionMetadataConfig(vlm_agenerate_timeout=TIMEOUT_S)) if vlm_on else None
|
| 212 |
+
report = {"index": index, "vlm": vlm_on, "model": os.environ.get("USDSEARCH_VISION_METADATA_MODEL", "") if vlm_on else "",
|
| 213 |
+
"timeout_s": TIMEOUT_S, "tries": TRIES, "assets": {}}
|
| 214 |
+
reembed_keys = spec.get("reembed") or []
|
| 215 |
+
if reembed_keys:
|
| 216 |
+
if not check_sources(index, reembed_keys, report):
|
| 217 |
+
print("IMAGE_OVERRIDES_REPORT " + json.dumps(report), flush=True)
|
| 218 |
+
print(" the fetched thumbnails are not the served ones: not re-embedding", flush=True)
|
| 219 |
+
return 1
|
| 220 |
+
await reembed(reembed_keys, emb, storage, report)
|
| 221 |
+
for key, rec in spec["assets"].items():
|
| 222 |
+
rep = report["assets"].setdefault(key, {"image": rec["image"], "ok": False})
|
| 223 |
+
t0 = time.time()
|
| 224 |
+
try:
|
| 225 |
+
img = Image.open(f"/overrides/{rec['image']}")
|
| 226 |
+
img.load()
|
| 227 |
+
sq = square(img)
|
| 228 |
+
thumb = sq.resize((THUMB_PX, THUMB_PX), Image.BILINEAR)
|
| 229 |
+
rep["source_size"] = list(img.size)
|
| 230 |
+
before = digests(os_doc(index, key))
|
| 231 |
+
|
| 232 |
+
async def embed():
|
| 233 |
+
batch, idx, errors = await emb.preprocess(data=[{"thumbnails": [thumb], "omni_path": key}])
|
| 234 |
+
if errors or idx != [0]:
|
| 235 |
+
raise RuntimeError(f"preprocess: {errors}")
|
| 236 |
+
return result_content(await emb.process_valid_items(batch_data=batch, indices=idx, sample_ids=[0]),
|
| 237 |
+
"thumbnail_to_embedding")
|
| 238 |
+
|
| 239 |
+
content = await with_retries("embedding", embed)
|
| 240 |
+
rep["embedding_dims"] = [len(e) for e in content["embedding"]]
|
| 241 |
+
await emb.add_metadata(path=key, content=content, storage_client=storage)
|
| 242 |
+
|
| 243 |
+
if vlm is not None:
|
| 244 |
+
vsrc = sq
|
| 245 |
+
if rec.get("vlm_image"):
|
| 246 |
+
vimg = Image.open(f"/overrides/{rec['vlm_image']}")
|
| 247 |
+
vimg.load()
|
| 248 |
+
vsrc = square(vimg)
|
| 249 |
+
rep["vlm_image"] = rec["vlm_image"]
|
| 250 |
+
vlm_img = vsrc.resize((VLM_PX, VLM_PX), Image.BILINEAR) if max(vsrc.size) > VLM_PX else vsrc
|
| 251 |
+
|
| 252 |
+
async def describe():
|
| 253 |
+
batch, idx, errors = await vlm.preprocess(data=[{vlm.batch_data_field: [vlm_img], "omni_path": key}])
|
| 254 |
+
if errors or idx != [0]:
|
| 255 |
+
raise RuntimeError(f"preprocess: {errors}")
|
| 256 |
+
return result_content(await vlm.process_valid_items(batch_data=batch, indices=idx, sample_ids=[0]),
|
| 257 |
+
"thumbnail_to_vision_metadata")
|
| 258 |
+
|
| 259 |
+
meta = await with_retries("vision metadata", describe)
|
| 260 |
+
fields = next(iter(meta.values()))
|
| 261 |
+
rep["vision_fields"] = sorted(f["name"] for f in fields)
|
| 262 |
+
caption = next((f for f in fields if f["name"] == "caption"), None)
|
| 263 |
+
rep["caption"] = (caption or {}).get("value_text")
|
| 264 |
+
await vlm.add_metadata(path=key, content=meta, storage_client=storage)
|
| 265 |
+
|
| 266 |
+
urllib.request.urlopen(urllib.request.Request(f"{OS_URL}/{index}/_refresh", method="POST"), timeout=120)
|
| 267 |
+
after = digests(os_doc(index, key))
|
| 268 |
+
# A re-run on the same image writes the same embedding: unchanged fields are not a failure.
|
| 269 |
+
rep["changed_fields"] = sorted(k for k in set(before) | set(after) if before.get(k) != after.get(k))
|
| 270 |
+
rep["ok"] = vlm is None or bool(rep.get("vision_fields"))
|
| 271 |
+
except Exception as exc: # noqa: BLE001 β reported per asset
|
| 272 |
+
rep["error"] = f"{type(exc).__name__}: {exc}"
|
| 273 |
+
traceback.print_exc()
|
| 274 |
+
rep["seconds"] = round(time.time() - t0, 1)
|
| 275 |
+
print(f" {'ok ' if rep['ok'] else 'FAIL'} {rec['image']}: changed {rep.get('changed_fields')}; "
|
| 276 |
+
f"caption {rep.get('caption')!r}; {rep['seconds']} s", flush=True)
|
| 277 |
+
ok = all(r["ok"] for r in report["assets"].values())
|
| 278 |
+
if reembed_keys:
|
| 279 |
+
urllib.request.urlopen(urllib.request.Request(f"{OS_URL}/{index}/_refresh", method="POST"), timeout=300)
|
| 280 |
+
# every doc carries its own vector: no zero ones and (almost) no shared ones
|
| 281 |
+
# The vectors themselves are checked on the exported dump (pipeline/image_overrides.py check_dump): a live
|
| 282 |
+
# index with k-NN derived source may not show them in _source.
|
| 283 |
+
# A failed fetch fails that asset, not the run: the dump gate after export decides (>= 98 % distinct vectors).
|
| 284 |
+
if report["reembed"]["failed_count"]:
|
| 285 |
+
print(f" WARNING: {report['reembed']['failed_count']} asset(s) not re-embedded: {report['reembed']['failed'][:5]}", flush=True)
|
| 286 |
+
print("IMAGE_OVERRIDES_REPORT " + json.dumps(report), flush=True)
|
| 287 |
+
return 0 if ok else 1
|
| 288 |
+
|
| 289 |
+
|
| 290 |
+
if __name__ == "__main__":
|
| 291 |
+
sys.exit(asyncio.run(main(sys.argv[1])))
|
pipeline/library_media.py
CHANGED
|
@@ -166,6 +166,27 @@ Web USD's full level (``.usdhd.tar``) and the media adds' HD files (spin.hd, rtx
|
|
| 166 |
``.hd.tar``'s (``skip``, after the choice, so they are never counted missing), and under GLB off it still asks for room
|
| 167 |
last, after the USD archives and every add. Revert: delete it, or set the variable, and restart.
|
| 168 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 169 |
Joint counts (2026-10-16, both GLB modes): every count of an asset's moving parts follows one rule, the joints the
|
| 170 |
viewer drives (``user_joints``: SimReady Central's libmedia_rules.user_joints since !733, vendored; the Explorer's
|
| 171 |
userJointCount and isUserJoint): never a mimic, a coupling or a closed loop's solved or closing joint. The archives
|
|
@@ -229,6 +250,10 @@ from .config import BUILD_DIR, Config
|
|
| 229 |
|
| 230 |
SUBDIR = "library-media"
|
| 231 |
ARCHIVE = re.compile(r"^media-\d{8}T\d{4}Z\.tar$") # media-20261005T1010Z.tar: the name sorts by time
|
|
|
|
|
|
|
|
|
|
|
|
|
| 232 |
# The Explorer's index key for this Space (/config.js in nginx.conf.template): the manifest's URL names it.
|
| 233 |
INDEX_KEY = "usdsearch-dev"
|
| 234 |
MEDIA_ROOT = BUILD_DIR / "library_media"
|
|
@@ -311,18 +336,68 @@ def run_prefix(cfg: Config, store, corpus: str) -> str:
|
|
| 311 |
return json.loads(pointer)["prefix"].rstrip("/")
|
| 312 |
|
| 313 |
|
| 314 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 315 |
"""The newest archive of the run at ``prefix``, else of the latest earlier run that has any; None: there is none.
|
| 316 |
-
Run ids start with the date (hierarchy/store.py), so they sort by age.
|
|
|
|
|
|
|
| 317 |
base, run = prefix.rsplit("/", 1)
|
| 318 |
earlier = [r for r in sorted(store.list_subprefixes(cfg, base), reverse=True) if r < run]
|
|
|
|
| 319 |
for r in [run] + earlier:
|
| 320 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 321 |
if names:
|
| 322 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 323 |
return None
|
| 324 |
|
| 325 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 326 |
def valid_manifest(path: Path) -> int | None:
|
| 327 |
"""The number of assets a manifest lists, or None when it is not one the Explorer reads (version 1, assets{})."""
|
| 328 |
try:
|
|
@@ -930,16 +1005,19 @@ def landed(tree: Path, entry_id: str, names: set[str]) -> bool:
|
|
| 930 |
return bool(names) and all((tree / entry_id / n).is_file() for n in names)
|
| 931 |
|
| 932 |
|
| 933 |
-
def publish_usd(tree: Path, blocks: dict[str, dict], created: Optional[dict[str, dict]] = None
|
|
|
|
| 934 |
"""Rewrite the served manifest with exactly ``blocks`` ({Space key: usd block}) as its entries' ``usd`` (every
|
| 935 |
other block untouched; an entry not in ``blocks`` loses any ``usd``), atomically. The number of entries carrying
|
| 936 |
one. An unchanged manifest is not written. ``created`` (an add's new entries, {Space key: {id, moving,
|
| 937 |
robot?}}): each one in ``blocks`` is served as exactly that plus its ``usd`` (added when absent) and the RTX media
|
| 938 |
an add attached to it (MEDIA_KINDS, kept as served); each one not in ``blocks`` leaves the manifest whole (it has
|
| 939 |
-
nothing but its USD and those).
|
|
|
|
|
|
|
| 940 |
path = tree / "manifest.json"
|
| 941 |
doc = json.loads(path.read_text())
|
| 942 |
-
created = created or {}
|
| 943 |
assets = doc.get("assets") if isinstance(doc.get("assets"), dict) else {}
|
| 944 |
changed = n = 0
|
| 945 |
for key, e in assets.items():
|
|
@@ -947,7 +1025,11 @@ def publish_usd(tree: Path, blocks: dict[str, dict], created: Optional[dict[str,
|
|
| 947 |
continue
|
| 948 |
if key in blocks:
|
| 949 |
changed += e.get("usd") != blocks[key]
|
|
|
|
| 950 |
e["usd"] = blocks[key]
|
|
|
|
|
|
|
|
|
|
| 951 |
n += 1
|
| 952 |
elif "usd" in e:
|
| 953 |
del e["usd"]
|
|
@@ -1012,7 +1094,7 @@ def fetch_hd(cfg: Config, store, uri: str, tree: Path, not_written: int = 0,
|
|
| 1012 |
MediaAdds.files) is never written, whatever the manifest lists. A manifest that lists no tier-2 model streams the
|
| 1013 |
``.hd-noglb.tar`` instead where there is one (``hd_noglb``, ``fetch_hd_noglb``; decided on the manifest, before
|
| 1014 |
``skip``)."""
|
| 1015 |
-
hd_uri =
|
| 1016 |
if cfg.get("LIBRARY_MEDIA_HD", "").strip().lower() in DISABLED:
|
| 1017 |
print("==> library media HD: off (LIBRARY_MEDIA_HD)")
|
| 1018 |
return 0
|
|
@@ -1024,6 +1106,13 @@ def fetch_hd(cfg: Config, store, uri: str, tree: Path, not_written: int = 0,
|
|
| 1024 |
allowed = {i: names for i, names in allowed.items() if names}
|
| 1025 |
if not allowed:
|
| 1026 |
return 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1027 |
noglb = hd_noglb(cfg, store, uri, manifest)
|
| 1028 |
del manifest
|
| 1029 |
if noglb is not None:
|
|
@@ -1056,7 +1145,7 @@ def hd_noglb(cfg: Config, store, uri: str, manifest: dict) -> Optional[tuple[str
|
|
| 1056 |
LIBRARY_MEDIA_HD_SPLIT is not off; else None, and the ``.hd.tar`` streams as before."""
|
| 1057 |
if hd_files(manifest) != hd_files(manifest, models=False):
|
| 1058 |
return None # an entry lists model_hd.glb: only the .hd.tar has it
|
| 1059 |
-
noglb =
|
| 1060 |
if cfg.get("LIBRARY_MEDIA_HD_SPLIT", "").strip().lower() in DISABLED:
|
| 1061 |
print(f"==> library media HD: {noglb.rsplit('/', 1)[-1]} off (LIBRARY_MEDIA_HD_SPLIT) β the .hd.tar")
|
| 1062 |
return None
|
|
@@ -1067,10 +1156,12 @@ def hd_noglb(cfg: Config, store, uri: str, manifest: dict) -> Optional[tuple[str
|
|
| 1067 |
return noglb, size
|
| 1068 |
|
| 1069 |
|
| 1070 |
-
def fetch_hd_noglb(cfg: Config, store, uri: str, size: int, tree: Path, allowed: dict[str, set[str]]
|
|
|
|
| 1071 |
"""Stream the ``.hd-noglb.tar`` ``uri`` (``size`` listed) into ``tree``, as ``fetch_hd`` streams the ``.hd.tar``:
|
| 1072 |
only members ``allowed`` lists, only with room for ``size``, a broken stream started again once. Returns the files
|
| 1073 |
-
written; logs the listed files still missing (the Explorer stays on tier 1 for those).
|
|
|
|
| 1074 |
if not room_for(tree, size, "HD (the archive without model_hd.glb)"):
|
| 1075 |
return 0
|
| 1076 |
t0 = time.time()
|
|
@@ -1080,7 +1171,8 @@ def fetch_hd_noglb(cfg: Config, store, uri: str, size: int, tree: Path, allowed:
|
|
| 1080 |
written, nbytes, skipped = got
|
| 1081 |
missing = sum(1 for i, names in allowed.items() for n in names if not (tree / i / n).is_file())
|
| 1082 |
print(f"==> library media HD: {written} files ({nbytes / 1e6:.0f} MB) from {uri} in {time.time() - t0:.0f} s, "
|
| 1083 |
-
|
|
|
|
| 1084 |
+ (f"; {skipped} members not in the manifest skipped" if skipped else "")
|
| 1085 |
+ (f"; {missing} listed HD files not in it (those stay on tier 1)" if missing else ""))
|
| 1086 |
return written
|
|
@@ -1092,11 +1184,12 @@ def viewer_format(cfg: Config) -> str:
|
|
| 1092 |
return value if value in VIEWER_FORMATS else "glb"
|
| 1093 |
|
| 1094 |
|
| 1095 |
-
def fetch_usd(cfg: Config, store, uri: str, tree: Path) -> int:
|
| 1096 |
"""Web USD (see the module doc): stream ``uri``'s ``.usd.tar`` sibling into the installed ``tree`` and publish the
|
| 1097 |
blocks whose tier-1 files landed (without their full level), then its ``.usdhd.tar`` and give back the full level
|
| 1098 |
of the blocks whose files landed. Returns the number of entries the served manifest then gives a ``usd``. Off
|
| 1099 |
-
unless VIEWER_FORMAT is usd or auto; each archive only with room to spare (``room_for``).
|
|
|
|
| 1100 |
fmt = viewer_format(cfg)
|
| 1101 |
if fmt == "glb":
|
| 1102 |
print("==> library media USD: off (VIEWER_FORMAT glb)")
|
|
@@ -1104,7 +1197,7 @@ def fetch_usd(cfg: Config, store, uri: str, tree: Path) -> int:
|
|
| 1104 |
if cfg.get("LIBRARY_MEDIA_USD", "").strip().lower() in DISABLED:
|
| 1105 |
print("==> library media USD: off (LIBRARY_MEDIA_USD)")
|
| 1106 |
return 0
|
| 1107 |
-
base =
|
| 1108 |
usd_uri = base + USD_SUFFIX
|
| 1109 |
size = listed_size(cfg, store, usd_uri)
|
| 1110 |
if size is None:
|
|
@@ -1120,7 +1213,7 @@ def fetch_usd(cfg: Config, store, uri: str, tree: Path) -> int:
|
|
| 1120 |
return 0
|
| 1121 |
blocks, left, written, nbytes, skipped = got
|
| 1122 |
tier1 = {k: usd_without_hd(b) for k, (i, b) in blocks.items() if landed(tree, i, usd_tier1_files(b))}
|
| 1123 |
-
shown = publish_usd(tree, tier1)
|
| 1124 |
print(f"==> library media USD: {shown} of {len(blocks)} blocks served at tier 1 ({written} files, "
|
| 1125 |
f"{nbytes / 1e6:.0f} MB) from {usd_uri} in {time.time() - t0:.0f} s"
|
| 1126 |
+ (f"; {left} blocks not in this manifest (or malformed) left out" if left else "")
|
|
@@ -1135,10 +1228,18 @@ def fetch_usd(cfg: Config, store, uri: str, tree: Path) -> int:
|
|
| 1135 |
file=sys.stderr)
|
| 1136 |
return shown
|
| 1137 |
if landed_hd:
|
| 1138 |
-
publish_usd(tree, {**tier1, **{k: blocks[k][1] for k in landed_hd}})
|
| 1139 |
return shown
|
| 1140 |
|
| 1141 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1142 |
def fetch_usd_hd(cfg: Config, store, uri: str, tree: Path, blocks: dict[str, tuple[str, dict]],
|
| 1143 |
what: str = "USD HD", media: Optional[dict[str, set[str]]] = None) -> set[str]:
|
| 1144 |
"""Stream the ``.usdhd.tar`` ``uri`` (or an add's ``.usdhd-add-<n>.tar``; ``what`` names it in the log) into
|
|
@@ -1183,8 +1284,8 @@ def served_usd(tree: Path) -> dict[str, dict]:
|
|
| 1183 |
|
| 1184 |
def usd_adds(cfg: Config, store, uri: str) -> list[tuple[int, str, int]]:
|
| 1185 |
"""The supplementary web USD archives of ``uri``'s stamp (media-<stamp>.usd-add-<n>.tar): [(n, uri, size)] by n."""
|
| 1186 |
-
folder, name = uri.rsplit("/", 1)
|
| 1187 |
-
pattern = re.compile(re.escape(name
|
| 1188 |
found = [(int(m.group(1)), f"{folder}/{rel}", size) for rel, size in store.list_objects(cfg, folder)
|
| 1189 |
if (m := pattern.fullmatch(rel))]
|
| 1190 |
return sorted(found)
|
|
@@ -2405,29 +2506,62 @@ def restore_glb(cfg: Config, store, uri: str, tree: Path, held: GlbHeld, ids: se
|
|
| 2405 |
return sum(missed.values())
|
| 2406 |
|
| 2407 |
|
| 2408 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2409 |
"""LIBRARY_MEDIA_GLB=off (``glb_off_why``): the web USD archives and adds first (the GLB stays served meanwhile, but
|
| 2410 |
for the GLB skip's folders, ``held``), then ``recount_moving``, then ``drop_glb``, then the held folders kept on
|
| 2411 |
their GLB get it (``restore_glb``), then the GLB HD archive. Each step fails on its own, like ``fetch``'s; a failed
|
| 2412 |
drop_glb leaves every entry on its GLB (the manifest is written whole or not at all; every held folder restored)
|
| 2413 |
and its model_hd.glb then written as before; drop_glb removes no file. The HD room check asks for the archive less
|
| 2414 |
the model_hd.glb drop_glb unlisted: they stream through, never written. The adds may create new entries
|
| 2415 |
-
(``NewEntries``): only here. The GLB HD archive never writes a file of the adds' RTX media.
|
|
|
|
| 2416 |
not_written = 0
|
| 2417 |
new = NewEntries()
|
|
|
|
| 2418 |
try:
|
| 2419 |
-
fetch_usd(cfg, store, uri, tree)
|
| 2420 |
except Exception as exc: # noqa: BLE001 β the GLB tiers are served already; web USD is a bonus
|
| 2421 |
-
print(f"==> library media USD: stopped ({type(exc).__name__}: {str(exc)[:300]}) β
|
| 2422 |
-
file=sys.stderr)
|
| 2423 |
try:
|
| 2424 |
fetch_usd_adds(cfg, store, uri, tree, new)
|
| 2425 |
except Exception as exc: # noqa: BLE001 β as above
|
| 2426 |
print(f"==> library media USD adds: stopped ({type(exc).__name__}: {str(exc)[:300]})", file=sys.stderr)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2427 |
recount(tree, held, new.aliases)
|
| 2428 |
restore: set[str] = set()
|
| 2429 |
try:
|
| 2430 |
-
|
|
|
|
|
|
|
|
|
|
| 2431 |
except Exception as exc: # noqa: BLE001 β every entry keeps its GLB
|
| 2432 |
restore = set(held.names) if held is not None else set()
|
| 2433 |
print(f"==> library media GLB: stopped ({type(exc).__name__}: {str(exc)[:300]}) β every entry keeps its GLB"
|
|
@@ -2462,16 +2596,23 @@ def fetch(env: dict[str, str], root: Path = MEDIA_ROOT, store=None, explorer: Op
|
|
| 2462 |
cfg = Config(values=dict(env))
|
| 2463 |
t0 = time.time()
|
| 2464 |
prefix = run_prefix(cfg, store, corpus)
|
| 2465 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2466 |
if uri is None:
|
| 2467 |
print(f"==> library media: none for {prefix} or an earlier run β none served")
|
| 2468 |
return 0
|
| 2469 |
-
|
| 2470 |
# the GLB skip: under GLB off in effect, the GLB tier-1 files are not written at install (see the module doc)
|
| 2471 |
-
held = GlbHeld() if off_why is None and cfg.get(GLB_SKIP, "").strip().lower() not in DISABLED
|
|
|
|
| 2472 |
root.mkdir(parents=True, exist_ok=True)
|
| 2473 |
tmp = root / f".{INDEX_KEY}.download"
|
| 2474 |
shutil.rmtree(tmp, ignore_errors=True)
|
|
|
|
| 2475 |
try:
|
| 2476 |
archive = tmp / "media.tar"
|
| 2477 |
if not store.download_file(cfg, uri, archive):
|
|
@@ -2483,7 +2624,10 @@ def fetch(env: dict[str, str], root: Path = MEDIA_ROOT, store=None, explorer: Op
|
|
| 2483 |
print(f"==> library media: {uri} has no usable manifest.json β none served")
|
| 2484 |
return 0
|
| 2485 |
if '"usd"' in (tmp / "tree" / "manifest.json").read_text():
|
| 2486 |
-
# web USD comes only from its own archives (fetch_usd), never from this one's manifest
|
|
|
|
|
|
|
|
|
|
| 2487 |
publish_usd(tmp / "tree", {})
|
| 2488 |
if held is not None:
|
| 2489 |
held.unlist(tmp / "tree")
|
|
@@ -2494,11 +2638,12 @@ def fetch(env: dict[str, str], root: Path = MEDIA_ROOT, store=None, explorer: Op
|
|
| 2494 |
nheld, held_bytes = held.files() if held is not None else (0, 0)
|
| 2495 |
print(f"==> library media: {count} assets from {uri} ({size / 1e6:.0f} MB) in {time.time() - t0:.0f} s"
|
| 2496 |
+ (f"; GLB off: {nheld} model.glb / articulation.json ({held_bytes / 1e6:.0f} MB) not written, only an entry "
|
| 2497 |
-
f"kept on its GLB gets them" if held is not None else "")
|
|
|
|
| 2498 |
if off_why:
|
| 2499 |
print(f"==> library media GLB: off asked, but {off_why} β every entry keeps its GLB")
|
| 2500 |
if off_why is None:
|
| 2501 |
-
fetch_usd_only(cfg, store, uri, root / INDEX_KEY, held)
|
| 2502 |
return count
|
| 2503 |
try:
|
| 2504 |
fetch_hd(cfg, store, uri, root / INDEX_KEY)
|
|
|
|
| 166 |
``.hd.tar``'s (``skip``, after the choice, so they are never counted missing), and under GLB off it still asks for room
|
| 167 |
last, after the USD archives and every add. Revert: delete it, or set the variable, and restart.
|
| 168 |
|
| 169 |
+
USD-only stamp (2026-10-16, under GLB off only): a stamp whose main archive is ``media-<stamp>.noglb.tar`` (``USD_ONLY``)
|
| 170 |
+
in place of ``media-<stamp>.tar`` carries no GLB at all: no entry of its manifest lists ``files.glb``,
|
| 171 |
+
``files.articulation`` or ``hd``, and none of those files is in it. ``python -m pipeline.library_media_pack
|
| 172 |
+
consolidate`` packs one from a served tree: a start's base and adds as ONE stamp, the blocks the adds replaced and the
|
| 173 |
+
GLB files gone, nothing left for an add to do. Its siblings are the stamp's ``.usd.tar``, ``.usdhd.tar`` and
|
| 174 |
+
``.hd-noglb.tar`` (its only HD archive: streamed whatever LIBRARY_MEDIA_HD_SPLIT says; there is no ``.hd.tar``). Its
|
| 175 |
+
manifest is the served one, ``usd`` blocks included: they leave it at install as any main archive's do, and each comes
|
| 176 |
+
back from the ``.usd.tar`` in the place that manifest gave it in its entry (``usd_places``), so a start serves that
|
| 177 |
+
manifest byte for byte. Its entries are base entries (an entry with no GLB needs no ``entry`` stub): until its block
|
| 178 |
+
lands, and for good where it does not, an entry is served with its RTX media and no model. The loader takes such a
|
| 179 |
+
stamp only while GLB off is in effect (``glb_off_why``); otherwise ``archive_uri`` skips it, says so, and takes the
|
| 180 |
+
newest stamp with a GLB (VIEWER_FORMAT glb, an Explorer without the USD-only reader: the stamp before it, with its adds,
|
| 181 |
+
served as before). Both kinds count for the newest stamp by their stamp (a GLB archive wins a tie); a USD-only stamp's
|
| 182 |
+
adds are its own, ``media-<stamp>.usd-add-<n>.tar``. ARCHIVE never matches its name, so a loader without this rule
|
| 183 |
+
never opens it: uploading one changes nothing until this loader runs. While it is served, an older stamp's adds in its
|
| 184 |
+
run are not applied (they were folded in, or came too late): one log line names them. An entry whose block does not
|
| 185 |
+
land (no room for the ``.usd.tar``, a broken stream) has no GLB to fall back on and is served with no 3D model: one
|
| 186 |
+
loud line counts and names them. Revert: LIBRARY_MEDIA_USD_ONLY=off (off / none / false / 0) and restart β the stamp
|
| 187 |
+
is set aside as under VIEWER_FORMAT glb, GLB off unchanged, and the newest stamp with a GLB comes back with its adds β
|
| 188 |
+
or delete its objects and restart.
|
| 189 |
+
|
| 190 |
Joint counts (2026-10-16, both GLB modes): every count of an asset's moving parts follows one rule, the joints the
|
| 191 |
viewer drives (``user_joints``: SimReady Central's libmedia_rules.user_joints since !733, vendored; the Explorer's
|
| 192 |
userJointCount and isUserJoint): never a mimic, a coupling or a closed loop's solved or closing joint. The archives
|
|
|
|
| 250 |
|
| 251 |
SUBDIR = "library-media"
|
| 252 |
ARCHIVE = re.compile(r"^media-\d{8}T\d{4}Z\.tar$") # media-20261005T1010Z.tar: the name sorts by time
|
| 253 |
+
# A USD-only stamp's main archive (see the module doc), taken only while GLB off is in effect; ARCHIVE never matches it.
|
| 254 |
+
USD_ONLY_SUFFIX = ".noglb.tar"
|
| 255 |
+
USD_ONLY = re.compile(r"^media-\d{8}T\d{4}Z\.noglb\.tar$")
|
| 256 |
+
STAMP_LEN = len("media-20261005T1010Z")
|
| 257 |
# The Explorer's index key for this Space (/config.js in nginx.conf.template): the manifest's URL names it.
|
| 258 |
INDEX_KEY = "usdsearch-dev"
|
| 259 |
MEDIA_ROOT = BUILD_DIR / "library_media"
|
|
|
|
| 336 |
return json.loads(pointer)["prefix"].rstrip("/")
|
| 337 |
|
| 338 |
|
| 339 |
+
USD_ONLY_NOT_ASKED = "LIBRARY_MEDIA_GLB is not off"
|
| 340 |
+
# A USD-only stamp's own switch (off / none / false / 0): set aside like an older Explorer's, with GLB off unchanged β
|
| 341 |
+
# the rollback that needs no S3 delete (see the module doc).
|
| 342 |
+
USD_ONLY_SWITCH = "LIBRARY_MEDIA_USD_ONLY"
|
| 343 |
+
USD_ONLY_SWITCHED_OFF = f"{USD_ONLY_SWITCH} is off"
|
| 344 |
+
ADDS_SHOWN = 8 # older stamps' adds a USD-only stamp leaves aside, named in a log line
|
| 345 |
+
OLDER_ADD = re.compile(r"^media-\d{8}T\d{4}Z" + USD_ADD + "$")
|
| 346 |
+
|
| 347 |
+
|
| 348 |
+
def archive_uri(cfg: Config, store, prefix: str, usd_only_why: Optional[str] = USD_ONLY_NOT_ASKED) -> Optional[str]:
|
| 349 |
"""The newest archive of the run at ``prefix``, else of the latest earlier run that has any; None: there is none.
|
| 350 |
+
Run ids start with the date (hierarchy/store.py), so they sort by age. A USD-only stamp's ``.noglb.tar`` (see the
|
| 351 |
+
module doc) counts only when ``usd_only_why`` is None (GLB off in effect), by its stamp, a GLB archive winning a
|
| 352 |
+
tie; else it is skipped, and the newest one newer than what is taken is named in a log line with ``usd_only_why``."""
|
| 353 |
base, run = prefix.rsplit("/", 1)
|
| 354 |
earlier = [r for r in sorted(store.list_subprefixes(cfg, base), reverse=True) if r < run]
|
| 355 |
+
skipped: Optional[tuple[str, str]] = None # (run, name) of the newest USD-only stamp skipped
|
| 356 |
for r in [run] + earlier:
|
| 357 |
+
rels = [rel for rel, _ in store.list_objects(cfg, f"{base}/{r}/{SUBDIR}")]
|
| 358 |
+
names = [rel for rel in rels if ARCHIVE.match(rel)]
|
| 359 |
+
only = [rel for rel in rels if USD_ONLY.match(rel)]
|
| 360 |
+
if usd_only_why is None:
|
| 361 |
+
names += only
|
| 362 |
+
elif only and skipped is None:
|
| 363 |
+
skipped = (r, max(only))
|
| 364 |
if names:
|
| 365 |
+
pick = max(names, key=lambda n: (n[:STAMP_LEN], ARCHIVE.match(n) is not None))
|
| 366 |
+
if skipped is not None and (skipped[0] > r or skipped[1][:STAMP_LEN] > pick[:STAMP_LEN]):
|
| 367 |
+
print(f"==> library media: {base}/{skipped[0]}/{SUBDIR}/{skipped[1]} skipped β "
|
| 368 |
+
f"{usd_only_set_aside(usd_only_why)}; {base}/{r}/{SUBDIR}/{pick} instead")
|
| 369 |
+
if USD_ONLY.match(pick):
|
| 370 |
+
# an older stamp's adds never apply to a newer stamp: said once, so one uploaded too late is seen
|
| 371 |
+
aside = sorted(rel for rel in rels if OLDER_ADD.match(rel) and rel[:STAMP_LEN] < pick[:STAMP_LEN])
|
| 372 |
+
if aside:
|
| 373 |
+
print(f"==> library media: a USD-only stamp ({pick}): {len(aside)} adds of older stamps not "
|
| 374 |
+
f"applied (only this stamp's own adds are): " + ", ".join(aside[:ADDS_SHOWN])
|
| 375 |
+
+ (f", +{len(aside) - ADDS_SHOWN} more" if len(aside) > ADDS_SHOWN else ""))
|
| 376 |
+
return f"{base}/{r}/{SUBDIR}/{pick}"
|
| 377 |
+
if skipped is not None:
|
| 378 |
+
print(f"==> library media: {base}/{skipped[0]}/{SUBDIR}/{skipped[1]} skipped β "
|
| 379 |
+
f"{usd_only_set_aside(usd_only_why)}; no archive with a GLB")
|
| 380 |
return None
|
| 381 |
|
| 382 |
|
| 383 |
+
def usd_only_set_aside(why: str) -> str:
|
| 384 |
+
"""Why a USD-only stamp is skipped, for ``archive_uri``'s log line."""
|
| 385 |
+
if why == USD_ONLY_SWITCHED_OFF:
|
| 386 |
+
return f"a USD-only stamp (no GLB) set aside ({why})"
|
| 387 |
+
return f"a USD-only stamp (no GLB) needs LIBRARY_MEDIA_GLB=off in effect ({why})"
|
| 388 |
+
|
| 389 |
+
|
| 390 |
+
def usd_only_stamp(uri: str) -> bool:
|
| 391 |
+
"""Whether ``uri`` is a USD-only stamp's main archive (media-<stamp>.noglb.tar)."""
|
| 392 |
+
return USD_ONLY.match(uri.rsplit("/", 1)[-1]) is not None
|
| 393 |
+
|
| 394 |
+
|
| 395 |
+
def stamp_base(uri: str) -> str:
|
| 396 |
+
"""A stamp's main archive ``uri`` (media-<stamp>.tar or media-<stamp>.noglb.tar) less its suffix: every sibling's
|
| 397 |
+
name is this plus its own (.hd.tar, .hd-noglb.tar, .usd.tar, .usdhd.tar, .usd-add-<n>.tar, β¦)."""
|
| 398 |
+
return uri[: -len(USD_ONLY_SUFFIX)] if usd_only_stamp(uri) else uri[: -len(".tar")]
|
| 399 |
+
|
| 400 |
+
|
| 401 |
def valid_manifest(path: Path) -> int | None:
|
| 402 |
"""The number of assets a manifest lists, or None when it is not one the Explorer reads (version 1, assets{})."""
|
| 403 |
try:
|
|
|
|
| 1005 |
return bool(names) and all((tree / entry_id / n).is_file() for n in names)
|
| 1006 |
|
| 1007 |
|
| 1008 |
+
def publish_usd(tree: Path, blocks: dict[str, dict], created: Optional[dict[str, dict]] = None,
|
| 1009 |
+
place: Optional[dict[str, list]] = None) -> int:
|
| 1010 |
"""Rewrite the served manifest with exactly ``blocks`` ({Space key: usd block}) as its entries' ``usd`` (every
|
| 1011 |
other block untouched; an entry not in ``blocks`` loses any ``usd``), atomically. The number of entries carrying
|
| 1012 |
one. An unchanged manifest is not written. ``created`` (an add's new entries, {Space key: {id, moving,
|
| 1013 |
robot?}}): each one in ``blocks`` is served as exactly that plus its ``usd`` (added when absent) and the RTX media
|
| 1014 |
an add attached to it (MEDIA_KINDS, kept as served); each one not in ``blocks`` leaves the manifest whole (it has
|
| 1015 |
+
nothing but its USD and those). ``place`` (a USD-only stamp's ``usd_places``: {Space key: its entry's keys in the
|
| 1016 |
+
stamp's manifest}): a block added where the entry had none takes the place that list gives it (keys the list does
|
| 1017 |
+
not name after it, in their order); else it goes last."""
|
| 1018 |
path = tree / "manifest.json"
|
| 1019 |
doc = json.loads(path.read_text())
|
| 1020 |
+
created, place = created or {}, place or {}
|
| 1021 |
assets = doc.get("assets") if isinstance(doc.get("assets"), dict) else {}
|
| 1022 |
changed = n = 0
|
| 1023 |
for key, e in assets.items():
|
|
|
|
| 1025 |
continue
|
| 1026 |
if key in blocks:
|
| 1027 |
changed += e.get("usd") != blocks[key]
|
| 1028 |
+
had = "usd" in e
|
| 1029 |
e["usd"] = blocks[key]
|
| 1030 |
+
if not had and key in place: # a value replaced while iterating: the dict keeps its size
|
| 1031 |
+
assets[key] = {k: e[k] for k in [*[k for k in place[key] if k in e], *[k for k in e
|
| 1032 |
+
if k not in place[key]]]}
|
| 1033 |
n += 1
|
| 1034 |
elif "usd" in e:
|
| 1035 |
del e["usd"]
|
|
|
|
| 1094 |
MediaAdds.files) is never written, whatever the manifest lists. A manifest that lists no tier-2 model streams the
|
| 1095 |
``.hd-noglb.tar`` instead where there is one (``hd_noglb``, ``fetch_hd_noglb``; decided on the manifest, before
|
| 1096 |
``skip``)."""
|
| 1097 |
+
hd_uri = stamp_base(uri) + HD_SUFFIX
|
| 1098 |
if cfg.get("LIBRARY_MEDIA_HD", "").strip().lower() in DISABLED:
|
| 1099 |
print("==> library media HD: off (LIBRARY_MEDIA_HD)")
|
| 1100 |
return 0
|
|
|
|
| 1106 |
allowed = {i: names for i, names in allowed.items() if names}
|
| 1107 |
if not allowed:
|
| 1108 |
return 0
|
| 1109 |
+
if usd_only_stamp(uri): # its only HD archive (see the module doc)
|
| 1110 |
+
only_uri = stamp_base(uri) + HD_NOGLB_SUFFIX
|
| 1111 |
+
only_size = listed_size(cfg, store, only_uri)
|
| 1112 |
+
if only_size is None:
|
| 1113 |
+
print(f"==> library media HD: none at {only_uri}")
|
| 1114 |
+
return 0
|
| 1115 |
+
return fetch_hd_noglb(cfg, store, only_uri, only_size, tree, allowed, usd_only=True)
|
| 1116 |
noglb = hd_noglb(cfg, store, uri, manifest)
|
| 1117 |
del manifest
|
| 1118 |
if noglb is not None:
|
|
|
|
| 1145 |
LIBRARY_MEDIA_HD_SPLIT is not off; else None, and the ``.hd.tar`` streams as before."""
|
| 1146 |
if hd_files(manifest) != hd_files(manifest, models=False):
|
| 1147 |
return None # an entry lists model_hd.glb: only the .hd.tar has it
|
| 1148 |
+
noglb = stamp_base(uri) + HD_NOGLB_SUFFIX
|
| 1149 |
if cfg.get("LIBRARY_MEDIA_HD_SPLIT", "").strip().lower() in DISABLED:
|
| 1150 |
print(f"==> library media HD: {noglb.rsplit('/', 1)[-1]} off (LIBRARY_MEDIA_HD_SPLIT) β the .hd.tar")
|
| 1151 |
return None
|
|
|
|
| 1156 |
return noglb, size
|
| 1157 |
|
| 1158 |
|
| 1159 |
+
def fetch_hd_noglb(cfg: Config, store, uri: str, size: int, tree: Path, allowed: dict[str, set[str]],
|
| 1160 |
+
usd_only: bool = False) -> int:
|
| 1161 |
"""Stream the ``.hd-noglb.tar`` ``uri`` (``size`` listed) into ``tree``, as ``fetch_hd`` streams the ``.hd.tar``:
|
| 1162 |
only members ``allowed`` lists, only with room for ``size``, a broken stream started again once. Returns the files
|
| 1163 |
+
written; logs the listed files still missing (the Explorer stays on tier 1 for those). ``usd_only``: a USD-only
|
| 1164 |
+
stamp's, its only HD archive (said so in the log)."""
|
| 1165 |
if not room_for(tree, size, "HD (the archive without model_hd.glb)"):
|
| 1166 |
return 0
|
| 1167 |
t0 = time.time()
|
|
|
|
| 1171 |
written, nbytes, skipped = got
|
| 1172 |
missing = sum(1 for i, names in allowed.items() for n in names if not (tree / i / n).is_file())
|
| 1173 |
print(f"==> library media HD: {written} files ({nbytes / 1e6:.0f} MB) from {uri} in {time.time() - t0:.0f} s, "
|
| 1174 |
+
+ ("the USD-only stamp's HD archive" if usd_only else
|
| 1175 |
+
"in place of the .hd.tar: the served manifest lists no tier-2 model")
|
| 1176 |
+ (f"; {skipped} members not in the manifest skipped" if skipped else "")
|
| 1177 |
+ (f"; {missing} listed HD files not in it (those stay on tier 1)" if missing else ""))
|
| 1178 |
return written
|
|
|
|
| 1184 |
return value if value in VIEWER_FORMATS else "glb"
|
| 1185 |
|
| 1186 |
|
| 1187 |
+
def fetch_usd(cfg: Config, store, uri: str, tree: Path, place: Optional[dict[str, list]] = None) -> int:
|
| 1188 |
"""Web USD (see the module doc): stream ``uri``'s ``.usd.tar`` sibling into the installed ``tree`` and publish the
|
| 1189 |
blocks whose tier-1 files landed (without their full level), then its ``.usdhd.tar`` and give back the full level
|
| 1190 |
of the blocks whose files landed. Returns the number of entries the served manifest then gives a ``usd``. Off
|
| 1191 |
+
unless VIEWER_FORMAT is usd or auto; each archive only with room to spare (``room_for``). ``place``: a USD-only
|
| 1192 |
+
stamp's ``usd_places`` (``publish_usd``)."""
|
| 1193 |
fmt = viewer_format(cfg)
|
| 1194 |
if fmt == "glb":
|
| 1195 |
print("==> library media USD: off (VIEWER_FORMAT glb)")
|
|
|
|
| 1197 |
if cfg.get("LIBRARY_MEDIA_USD", "").strip().lower() in DISABLED:
|
| 1198 |
print("==> library media USD: off (LIBRARY_MEDIA_USD)")
|
| 1199 |
return 0
|
| 1200 |
+
base = stamp_base(uri)
|
| 1201 |
usd_uri = base + USD_SUFFIX
|
| 1202 |
size = listed_size(cfg, store, usd_uri)
|
| 1203 |
if size is None:
|
|
|
|
| 1213 |
return 0
|
| 1214 |
blocks, left, written, nbytes, skipped = got
|
| 1215 |
tier1 = {k: usd_without_hd(b) for k, (i, b) in blocks.items() if landed(tree, i, usd_tier1_files(b))}
|
| 1216 |
+
shown = publish_usd(tree, tier1, place=place)
|
| 1217 |
print(f"==> library media USD: {shown} of {len(blocks)} blocks served at tier 1 ({written} files, "
|
| 1218 |
f"{nbytes / 1e6:.0f} MB) from {usd_uri} in {time.time() - t0:.0f} s"
|
| 1219 |
+ (f"; {left} blocks not in this manifest (or malformed) left out" if left else "")
|
|
|
|
| 1228 |
file=sys.stderr)
|
| 1229 |
return shown
|
| 1230 |
if landed_hd:
|
| 1231 |
+
publish_usd(tree, {**tier1, **{k: blocks[k][1] for k in landed_hd}}, place=place)
|
| 1232 |
return shown
|
| 1233 |
|
| 1234 |
|
| 1235 |
+
def usd_places(tree: Path) -> dict[str, list]:
|
| 1236 |
+
"""A USD-only stamp's main manifest as unpacked under ``tree``, before its ``usd`` blocks leave it: {Space key:
|
| 1237 |
+
that entry's keys in order} for every entry that carries one (``publish_usd``'s ``place``)."""
|
| 1238 |
+
assets = json.loads((tree / "manifest.json").read_text()).get("assets")
|
| 1239 |
+
return {k: list(e) for k, e in (assets.items() if isinstance(assets, dict) else ())
|
| 1240 |
+
if isinstance(e, dict) and "usd" in e}
|
| 1241 |
+
|
| 1242 |
+
|
| 1243 |
def fetch_usd_hd(cfg: Config, store, uri: str, tree: Path, blocks: dict[str, tuple[str, dict]],
|
| 1244 |
what: str = "USD HD", media: Optional[dict[str, set[str]]] = None) -> set[str]:
|
| 1245 |
"""Stream the ``.usdhd.tar`` ``uri`` (or an add's ``.usdhd-add-<n>.tar``; ``what`` names it in the log) into
|
|
|
|
| 1284 |
|
| 1285 |
def usd_adds(cfg: Config, store, uri: str) -> list[tuple[int, str, int]]:
|
| 1286 |
"""The supplementary web USD archives of ``uri``'s stamp (media-<stamp>.usd-add-<n>.tar): [(n, uri, size)] by n."""
|
| 1287 |
+
folder, name = stamp_base(uri).rsplit("/", 1)
|
| 1288 |
+
pattern = re.compile(re.escape(name) + USD_ADD)
|
| 1289 |
found = [(int(m.group(1)), f"{folder}/{rel}", size) for rel, size in store.list_objects(cfg, folder)
|
| 1290 |
if (m := pattern.fullmatch(rel))]
|
| 1291 |
return sorted(found)
|
|
|
|
| 2506 |
return sum(missed.values())
|
| 2507 |
|
| 2508 |
|
| 2509 |
+
def usd_only_without_model(tree: Path, place: dict[str, list]) -> list[str]:
|
| 2510 |
+
"""A USD-only stamp's entries (``place``: those its manifest gave a ``usd`` block) the served manifest under
|
| 2511 |
+
``tree`` lists with no ``usd`` now: served with no 3D model at all. Their ids."""
|
| 2512 |
+
assets = json.loads((tree / "manifest.json").read_text()).get("assets")
|
| 2513 |
+
assets = assets if isinstance(assets, dict) else {}
|
| 2514 |
+
return [str(assets[k].get("id")) for k in place if isinstance(assets.get(k), dict) and "usd" not in assets[k]]
|
| 2515 |
+
|
| 2516 |
+
|
| 2517 |
+
def lists_glb(tree: Path) -> bool:
|
| 2518 |
+
"""Whether an entry of the served manifest under ``tree`` lists a GLB (``files.glb`` / ``files.articulation``) or a
|
| 2519 |
+
tier-2 model (``hd``)."""
|
| 2520 |
+
assets = json.loads((tree / "manifest.json").read_text()).get("assets")
|
| 2521 |
+
return any(isinstance(e, dict) and (e.get("hd") is not None or (isinstance(e.get("files"), dict) and any(
|
| 2522 |
+
e["files"].get(r) for r in GLB_ROLES))) for e in (assets.values() if isinstance(assets, dict) else ()))
|
| 2523 |
+
|
| 2524 |
+
|
| 2525 |
+
def fetch_usd_only(cfg: Config, store, uri: str, tree: Path, held: Optional[GlbHeld] = None,
|
| 2526 |
+
place: Optional[dict[str, list]] = None) -> None:
|
| 2527 |
"""LIBRARY_MEDIA_GLB=off (``glb_off_why``): the web USD archives and adds first (the GLB stays served meanwhile, but
|
| 2528 |
for the GLB skip's folders, ``held``), then ``recount_moving``, then ``drop_glb``, then the held folders kept on
|
| 2529 |
their GLB get it (``restore_glb``), then the GLB HD archive. Each step fails on its own, like ``fetch``'s; a failed
|
| 2530 |
drop_glb leaves every entry on its GLB (the manifest is written whole or not at all; every held folder restored)
|
| 2531 |
and its model_hd.glb then written as before; drop_glb removes no file. The HD room check asks for the archive less
|
| 2532 |
the model_hd.glb drop_glb unlisted: they stream through, never written. The adds may create new entries
|
| 2533 |
+
(``NewEntries``): only here. The GLB HD archive never writes a file of the adds' RTX media. A USD-only stamp
|
| 2534 |
+
(``place``, its ``usd_places``): its blocks back in their places, and no ``drop_glb`` while no entry lists a GLB."""
|
| 2535 |
not_written = 0
|
| 2536 |
new = NewEntries()
|
| 2537 |
+
only = usd_only_stamp(uri)
|
| 2538 |
try:
|
| 2539 |
+
fetch_usd(cfg, store, uri, tree, place)
|
| 2540 |
except Exception as exc: # noqa: BLE001 β the GLB tiers are served already; web USD is a bonus
|
| 2541 |
+
print(f"==> library media USD: stopped ({type(exc).__name__}: {str(exc)[:300]}) β "
|
| 2542 |
+
+ ("a USD-only stamp has no GLB to fall back on" if only else "the GLB stays"), file=sys.stderr)
|
| 2543 |
try:
|
| 2544 |
fetch_usd_adds(cfg, store, uri, tree, new)
|
| 2545 |
except Exception as exc: # noqa: BLE001 β as above
|
| 2546 |
print(f"==> library media USD adds: stopped ({type(exc).__name__}: {str(exc)[:300]})", file=sys.stderr)
|
| 2547 |
+
if only and place:
|
| 2548 |
+
try:
|
| 2549 |
+
no_model = usd_only_without_model(tree, place)
|
| 2550 |
+
except Exception as exc: # noqa: BLE001 β a log line only
|
| 2551 |
+
no_model = [f"not counted ({type(exc).__name__})"]
|
| 2552 |
+
if no_model:
|
| 2553 |
+
print(f"==> library media USD: a USD-only stamp: {len(no_model)} of {len(place)} entries served with NO "
|
| 2554 |
+
f"3D model (their web USD did not land; no GLB to fall back on): " + ", ".join(no_model[:ADDS_SHOWN])
|
| 2555 |
+
+ (f", +{len(no_model) - ADDS_SHOWN} more" if len(no_model) > ADDS_SHOWN else "")
|
| 2556 |
+
+ f" β revert: {USD_ONLY_SWITCH}=off and restart (the stamp before it, with its adds)",
|
| 2557 |
+
file=sys.stderr)
|
| 2558 |
recount(tree, held, new.aliases)
|
| 2559 |
restore: set[str] = set()
|
| 2560 |
try:
|
| 2561 |
+
if usd_only_stamp(uri) and not lists_glb(tree):
|
| 2562 |
+
print("==> library media GLB: off β a USD-only stamp: no entry lists a GLB or a model_hd.glb")
|
| 2563 |
+
else:
|
| 2564 |
+
_, not_written, restore = drop_glb(tree, held, new.aliases)
|
| 2565 |
except Exception as exc: # noqa: BLE001 β every entry keeps its GLB
|
| 2566 |
restore = set(held.names) if held is not None else set()
|
| 2567 |
print(f"==> library media GLB: stopped ({type(exc).__name__}: {str(exc)[:300]}) β every entry keeps its GLB"
|
|
|
|
| 2596 |
cfg = Config(values=dict(env))
|
| 2597 |
t0 = time.time()
|
| 2598 |
prefix = run_prefix(cfg, store, corpus)
|
| 2599 |
+
off_why = glb_off_why(cfg, EXPLORER_ROOT if explorer is None else explorer)
|
| 2600 |
+
# a USD-only stamp only while GLB off is in effect and its own switch is not off (see the module doc)
|
| 2601 |
+
usd_only_why = None if off_why is None else (off_why or USD_ONLY_NOT_ASKED)
|
| 2602 |
+
if usd_only_why is None and cfg.get(USD_ONLY_SWITCH, "").strip().lower() in DISABLED:
|
| 2603 |
+
usd_only_why = USD_ONLY_SWITCHED_OFF
|
| 2604 |
+
uri = archive_uri(cfg, store, prefix, usd_only_why)
|
| 2605 |
if uri is None:
|
| 2606 |
print(f"==> library media: none for {prefix} or an earlier run β none served")
|
| 2607 |
return 0
|
| 2608 |
+
only = usd_only_stamp(uri)
|
| 2609 |
# the GLB skip: under GLB off in effect, the GLB tier-1 files are not written at install (see the module doc)
|
| 2610 |
+
held = (GlbHeld() if off_why is None and not only and cfg.get(GLB_SKIP, "").strip().lower() not in DISABLED
|
| 2611 |
+
else None)
|
| 2612 |
root.mkdir(parents=True, exist_ok=True)
|
| 2613 |
tmp = root / f".{INDEX_KEY}.download"
|
| 2614 |
shutil.rmtree(tmp, ignore_errors=True)
|
| 2615 |
+
place: dict[str, list] = {}
|
| 2616 |
try:
|
| 2617 |
archive = tmp / "media.tar"
|
| 2618 |
if not store.download_file(cfg, uri, archive):
|
|
|
|
| 2624 |
print(f"==> library media: {uri} has no usable manifest.json β none served")
|
| 2625 |
return 0
|
| 2626 |
if '"usd"' in (tmp / "tree" / "manifest.json").read_text():
|
| 2627 |
+
# web USD comes only from its own archives (fetch_usd), never from this one's manifest; a USD-only
|
| 2628 |
+
# stamp's blocks come back in the places its manifest gives them
|
| 2629 |
+
if only:
|
| 2630 |
+
place = usd_places(tmp / "tree")
|
| 2631 |
publish_usd(tmp / "tree", {})
|
| 2632 |
if held is not None:
|
| 2633 |
held.unlist(tmp / "tree")
|
|
|
|
| 2638 |
nheld, held_bytes = held.files() if held is not None else (0, 0)
|
| 2639 |
print(f"==> library media: {count} assets from {uri} ({size / 1e6:.0f} MB) in {time.time() - t0:.0f} s"
|
| 2640 |
+ (f"; GLB off: {nheld} model.glb / articulation.json ({held_bytes / 1e6:.0f} MB) not written, only an entry "
|
| 2641 |
+
f"kept on its GLB gets them" if held is not None else "")
|
| 2642 |
+
+ (f"; a USD-only stamp: {len(place)} web USD blocks to come from its .usd.tar" if only else ""))
|
| 2643 |
if off_why:
|
| 2644 |
print(f"==> library media GLB: off asked, but {off_why} β every entry keeps its GLB")
|
| 2645 |
if off_why is None:
|
| 2646 |
+
fetch_usd_only(cfg, store, uri, root / INDEX_KEY, held, place)
|
| 2647 |
return count
|
| 2648 |
try:
|
| 2649 |
fetch_hd(cfg, store, uri, root / INDEX_KEY)
|
pipeline/library_media_pack.py
ADDED
|
@@ -0,0 +1,522 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The packing side of pipeline/library_media.py's archives: the ``.hd-noglb.tar``, and a USD-only stamp. Run where
|
| 2 |
+
the media are built or stored (a lab pod, a one-off Action), never on a Space.
|
| 3 |
+
|
| 4 |
+
python -m pipeline.library_media_pack hd-noglb --manifest <manifest.json | media-<stamp>.tar> \\
|
| 5 |
+
[--in <media-<stamp>.hd.tar> | -] [--out <media-<stamp>.hd-noglb.tar> | -] [--summary <file.json>] \\
|
| 6 |
+
[--require-complete]
|
| 7 |
+
python -m pipeline.library_media_pack consolidate --tree <served tree> --stamp <YYYYMMDDTHHMMZ> --out <dir> \\
|
| 8 |
+
[--part-size N] [--jobs N] [--hash-only] [--drop-orphans] [--run <run id>] [--sources <listing.json>]
|
| 9 |
+
|
| 10 |
+
A stamp's ``.hd.tar`` carries the tier-2 models (model_hd.glb) beside the spin HD frames and RTX clip HD files; its
|
| 11 |
+
``.hd-noglb.tar`` carries those two alone (library_media's module doc, "HD without the models"). This reads the
|
| 12 |
+
``.hd.tar`` once, as a stream, and writes every member ``library_media.hd_files(manifest, models=False)`` lists (a
|
| 13 |
+
regular file under ``<id>/<path>``: ``library_media.member_listed``, the loader's own rule), header fields (name, size,
|
| 14 |
+
mtime, mode, owner), bytes and order unchanged (``tarfile`` re-encodes the headers as pax, so the archive is not a
|
| 15 |
+
byte-for-byte cut of the source); everything else (model_hd.glb, directories, members the manifest does not list) is
|
| 16 |
+
left out. Streamed by the loader under a manifest that lists no tier-2 model, it writes exactly the files the
|
| 17 |
+
``.hd.tar`` would.
|
| 18 |
+
|
| 19 |
+
``--manifest``: the stamp's manifest, a ``manifest.json`` or the ``media-<stamp>.tar`` whose ``manifest.json`` member
|
| 20 |
+
is read. ``--out`` is never written over. The summary (JSON; on stderr unless ``--summary``): members kept and left
|
| 21 |
+
out by kind with their bytes, the source's and the output's size and sha256, and the listed files the source lacks
|
| 22 |
+
(``--require-complete``: exit 2 when there is any). A failure leaves no output file; on stdout it leaves no
|
| 23 |
+
end-of-archive marker (a reader sees a truncated archive), so a caller storing the stream must check its size and
|
| 24 |
+
sha256 against the summary before keeping it.
|
| 25 |
+
|
| 26 |
+
``consolidate`` (library_media's module doc, "USD-only stamp"): the tree a Space serves under GLB off β its
|
| 27 |
+
``.build/library_media/usdsearch-dev/`` after a start, every entry on its web USD, base and adds applied β packed as
|
| 28 |
+
ONE stamp the loader starts from alone: ``media-<stamp>.noglb.tar`` (the served ``manifest.json`` first, ``usd`` blocks
|
| 29 |
+
included, then every file below that no other archive carries), ``.usd.tar`` (``usd-manifest.json`` first: each served
|
| 30 |
+
block under its key and id, then each block's ``usd_tier1_files``), ``.usdhd.tar`` (each served block's
|
| 31 |
+
``usd_hd_files``: only a block served at its full level names them) and ``.hd-noglb.tar`` (the files
|
| 32 |
+
``hd_files(manifest, models=False)`` lists that are there: spin HD frames, RTX clip HD files). Every file of the tree
|
| 33 |
+
goes in exactly one archive, so a start of the stamp alone writes the same tree and serves the same manifest byte for
|
| 34 |
+
byte. Refused, before anything is written: a manifest that is not one, an entry that lists a GLB (``files.glb``,
|
| 35 |
+
``files.articulation``) or a tier-2 model (``hd``: an entry kept on its GLB cannot be USD-only), a block the loader
|
| 36 |
+
would not place (``usd_block_ok``) or whose files are not all there, anything but regular files, a file the loader
|
| 37 |
+
would never write back (a name ``member_path`` cannot read, a dot-file, a ``.part``), a GLB file (``model.glb``,
|
| 38 |
+
``model_hd.glb``, ``articulation.json`` in an id folder) no served block names. ``--drop-orphans`` leaves out the
|
| 39 |
+
folders of ids no entry carries (never served; listed in the summary either way). ``--sources``: the run's
|
| 40 |
+
``library-media/`` listing the tree was served from (``{"run", "objects": {name: {size, sha256?}}}``), refused when a
|
| 41 |
+
name already uses the stamp or a stamp in it is not older (the loader serves only the newest), and recorded in
|
| 42 |
+
pack.json with the stamps and the adds folded, so the upload can refuse a run that changed since (an add uploaded after
|
| 43 |
+
the pack would be left aside). pack.json also records the Python that packed it. Deterministic: the same tree and
|
| 44 |
+
stamp give the same bytes β members sorted by path after the first, pax headers with mode 0644, owner 0, no user or
|
| 45 |
+
group names, mtime the stamp's own time (a fixed value, and nginx's ETag still changes from one stamp to the next).
|
| 46 |
+
Each archive is written as ``<dir>/<kind>.tar.part-NNN`` parts of at most ``--part-size`` bytes (default 1,900 MiB:
|
| 47 |
+
under GitHub's 2 GiB release asset cap) with ``pack.json`` (names, sizes, sha256 per archive and part, counts) and
|
| 48 |
+
``SHA256SUMS``; ``--hash-only`` writes no part, only ``pack.json`` with the sizes and sha256s (a determinism check).
|
| 49 |
+
``--out`` must not exist; a failure removes it.
|
| 50 |
+
"""
|
| 51 |
+
|
| 52 |
+
from __future__ import annotations
|
| 53 |
+
|
| 54 |
+
import argparse
|
| 55 |
+
import calendar
|
| 56 |
+
import hashlib
|
| 57 |
+
import json
|
| 58 |
+
import os
|
| 59 |
+
import platform
|
| 60 |
+
import re
|
| 61 |
+
import shutil
|
| 62 |
+
import stat
|
| 63 |
+
import sys
|
| 64 |
+
import tarfile
|
| 65 |
+
import time
|
| 66 |
+
from concurrent.futures import ProcessPoolExecutor
|
| 67 |
+
from pathlib import Path
|
| 68 |
+
from typing import BinaryIO, Optional
|
| 69 |
+
|
| 70 |
+
from . import library_media as lm
|
| 71 |
+
|
| 72 |
+
BUFSIZE = 1 << 20
|
| 73 |
+
MISSING_SHOWN = 20
|
| 74 |
+
PART_SIZE = 1900 << 20 # 1,992,294,400 B: every part under GitHub's 2 GiB release asset cap
|
| 75 |
+
STAMP = re.compile(r"^\d{8}T\d{4}Z$")
|
| 76 |
+
KINDS = ("main", "usd", "usdhd", "hd")
|
| 77 |
+
SUFFIX = {"main": lm.USD_ONLY_SUFFIX, "usd": lm.USD_SUFFIX, "usdhd": lm.USD_HD_SUFFIX, "hd": lm.HD_NOGLB_SUFFIX}
|
| 78 |
+
PART = {"main": "noglb.tar", "usd": "usd.tar", "usdhd": "usdhd.tar", "hd": "hd-noglb.tar"}
|
| 79 |
+
# A GLB tier's file names in an id folder: a USD-only stamp carries none (one no served block names is refused).
|
| 80 |
+
GLB_FILES = {"model.glb", "model_hd.glb", "articulation.json"}
|
| 81 |
+
# A stamp's main archive, either kind, in a run's listing (``--sources``).
|
| 82 |
+
MAIN_ARCHIVE = re.compile(r"^media-(\d{8}T\d{4}Z)(\.noglb)?\.tar$")
|
| 83 |
+
ADD_OBJECT = re.compile(r"^media-\d{8}T\d{4}Z\.usd(hd)?-add-[1-9][0-9]{0,2}\.tar$")
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
class Counted:
|
| 87 |
+
"""A binary stream that hashes (sha256) and counts every byte read from or written to ``raw``."""
|
| 88 |
+
|
| 89 |
+
def __init__(self, raw):
|
| 90 |
+
self.raw, self.sha, self.size = raw, hashlib.sha256(), 0
|
| 91 |
+
|
| 92 |
+
def read(self, n: int = -1) -> bytes:
|
| 93 |
+
b = self.raw.read(n) if n is not None and n >= 0 else self.raw.read()
|
| 94 |
+
self.sha.update(b)
|
| 95 |
+
self.size += len(b)
|
| 96 |
+
return b
|
| 97 |
+
|
| 98 |
+
def write(self, b) -> int:
|
| 99 |
+
self.raw.write(b)
|
| 100 |
+
self.sha.update(b)
|
| 101 |
+
self.size += len(b)
|
| 102 |
+
return len(b)
|
| 103 |
+
|
| 104 |
+
def flush(self) -> None:
|
| 105 |
+
if hasattr(self.raw, "flush"):
|
| 106 |
+
self.raw.flush()
|
| 107 |
+
|
| 108 |
+
def drain(self) -> None:
|
| 109 |
+
"""Read the rest (a tar's end blocks and record padding), so ``size`` and ``sha`` cover the whole source."""
|
| 110 |
+
for _ in iter(lambda: self.read(BUFSIZE), b""):
|
| 111 |
+
pass
|
| 112 |
+
|
| 113 |
+
def facts(self) -> dict:
|
| 114 |
+
return {"bytes": self.size, "sha256": self.sha.hexdigest()}
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def split_hd(src: BinaryIO, out: BinaryIO, manifest: dict) -> dict:
|
| 118 |
+
"""Write to ``out`` the ``.hd-noglb.tar`` of the ``.hd.tar`` read from ``src`` (once, in order) for ``manifest``.
|
| 119 |
+
Returns the summary (the module doc)."""
|
| 120 |
+
keep = lm.hd_files(manifest, models=False)
|
| 121 |
+
models = lm.hd_files(manifest)
|
| 122 |
+
reader, writer = Counted(src), Counted(out)
|
| 123 |
+
kept = {"spin_hd": [0, 0], "clip_hd": [0, 0]}
|
| 124 |
+
left = {"model_hd": [0, 0], "unlisted": [0, 0], "directory": [0, 0], "other": [0, 0]}
|
| 125 |
+
seen: set[tuple[str, str]] = set()
|
| 126 |
+
duplicates = 0
|
| 127 |
+
with tarfile.open(fileobj=reader, mode="r|", bufsize=BUFSIZE) as tin, \
|
| 128 |
+
tarfile.open(fileobj=writer, mode="w|", bufsize=BUFSIZE) as tout:
|
| 129 |
+
for m in tin:
|
| 130 |
+
where = lm.member_path(m.name)
|
| 131 |
+
if lm.member_listed(m, keep):
|
| 132 |
+
with tin.extractfile(m) as data:
|
| 133 |
+
tout.addfile(m, data)
|
| 134 |
+
kind = "spin_hd" if where[1].startswith("spin_hd_") else "clip_hd"
|
| 135 |
+
kept[kind][0] += 1
|
| 136 |
+
kept[kind][1] += m.size
|
| 137 |
+
duplicates += where in seen
|
| 138 |
+
seen.add(where)
|
| 139 |
+
continue
|
| 140 |
+
if m.isdir():
|
| 141 |
+
kind = "directory"
|
| 142 |
+
elif not m.isfile():
|
| 143 |
+
kind = "other"
|
| 144 |
+
elif where is not None and where[1] in models.get(where[0], ()):
|
| 145 |
+
kind = "model_hd"
|
| 146 |
+
else:
|
| 147 |
+
kind = "unlisted"
|
| 148 |
+
left[kind][0] += 1
|
| 149 |
+
left[kind][1] += m.size if m.isfile() else 0
|
| 150 |
+
reader.drain()
|
| 151 |
+
missing = sorted(f"{i}/{n}" for i, names in keep.items() for n in names if (i, n) not in seen)
|
| 152 |
+
listed = sum(len(names) for names in keep.values())
|
| 153 |
+
return {
|
| 154 |
+
"source": reader.facts(),
|
| 155 |
+
"output": {**writer.facts(), "members": sum(n for n, _ in kept.values())},
|
| 156 |
+
"kept": {k: {"members": n, "bytes": b} for k, (n, b) in kept.items()},
|
| 157 |
+
"left_out": {k: {"members": n, "bytes": b} for k, (n, b) in left.items()},
|
| 158 |
+
"entries": len(keep),
|
| 159 |
+
"entries_with_models": sum(1 for i, names in models.items() if names - keep.get(i, set())),
|
| 160 |
+
"listed": listed,
|
| 161 |
+
"listed_missing": len(missing),
|
| 162 |
+
"missing_sample": missing[:MISSING_SHOWN],
|
| 163 |
+
"duplicates": duplicates,
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def read_manifest(path: Path) -> tuple[dict, str]:
|
| 168 |
+
"""The manifest at ``path`` (a manifest.json, or a media-<stamp>.tar whose manifest.json member is read as a
|
| 169 |
+
stream) and the sha256 of its bytes. Not one the Explorer reads (version 1, assets{}): SystemExit."""
|
| 170 |
+
raw = None
|
| 171 |
+
if path.suffix == ".tar":
|
| 172 |
+
with open(path, "rb") as fh, tarfile.open(fileobj=fh, mode="r|", bufsize=BUFSIZE) as tf:
|
| 173 |
+
for m in tf:
|
| 174 |
+
if m.isfile() and m.name.removeprefix("./") == "manifest.json":
|
| 175 |
+
with tf.extractfile(m) as data:
|
| 176 |
+
raw = data.read()
|
| 177 |
+
break
|
| 178 |
+
if raw is None:
|
| 179 |
+
raise SystemExit(f"{path}: no manifest.json member")
|
| 180 |
+
else:
|
| 181 |
+
raw = path.read_bytes()
|
| 182 |
+
try:
|
| 183 |
+
doc = json.loads(raw)
|
| 184 |
+
except ValueError as exc:
|
| 185 |
+
raise SystemExit(f"{path}: manifest is not JSON ({exc})") from None
|
| 186 |
+
if not isinstance(doc, dict) or doc.get("version") != 1 or not isinstance(doc.get("assets"), dict):
|
| 187 |
+
raise SystemExit(f"{path}: not a library media manifest (version 1, assets{{}})")
|
| 188 |
+
return doc, hashlib.sha256(raw).hexdigest()
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
def hd_noglb(a: argparse.Namespace) -> int:
|
| 192 |
+
manifest, manifest_sha = read_manifest(a.manifest)
|
| 193 |
+
out_path = None if a.out == "-" else Path(a.out)
|
| 194 |
+
src = sys.stdin.buffer if a.src == "-" else open(a.src, "rb")
|
| 195 |
+
try:
|
| 196 |
+
out = sys.stdout.buffer if out_path is None else open(out_path, "xb") # never written over
|
| 197 |
+
except BaseException:
|
| 198 |
+
if src is not sys.stdin.buffer:
|
| 199 |
+
src.close()
|
| 200 |
+
raise
|
| 201 |
+
try:
|
| 202 |
+
summary = split_hd(src, out, manifest)
|
| 203 |
+
except BaseException:
|
| 204 |
+
if out_path is not None:
|
| 205 |
+
out.close()
|
| 206 |
+
out_path.unlink(missing_ok=True)
|
| 207 |
+
raise
|
| 208 |
+
finally:
|
| 209 |
+
if src is not sys.stdin.buffer:
|
| 210 |
+
src.close()
|
| 211 |
+
if out_path is not None:
|
| 212 |
+
out.close()
|
| 213 |
+
else:
|
| 214 |
+
out.flush()
|
| 215 |
+
summary = {"manifest_sha256": manifest_sha, "manifest_entries": len(manifest["assets"]), **summary}
|
| 216 |
+
text = json.dumps(summary, indent=1)
|
| 217 |
+
if a.summary:
|
| 218 |
+
a.summary.write_text(text + "\n")
|
| 219 |
+
else:
|
| 220 |
+
print(text, file=sys.stderr)
|
| 221 |
+
if a.require_complete and summary["listed_missing"]:
|
| 222 |
+
print(f"{summary['listed_missing']} listed HD files are not in the source "
|
| 223 |
+
f"(e.g. {', '.join(summary['missing_sample'][:3])})", file=sys.stderr)
|
| 224 |
+
return 2
|
| 225 |
+
return 0
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
# --- consolidate: a served tree as ONE USD-only stamp (the module doc) ------------------------------------------------
|
| 229 |
+
|
| 230 |
+
class Parts:
|
| 231 |
+
"""A write-only stream into ``<folder>/<name>.part-NNN`` files of at most ``limit`` bytes each (``folder`` None:
|
| 232 |
+
nothing written, only hashed), hashing (sha256) the whole stream and each part."""
|
| 233 |
+
|
| 234 |
+
def __init__(self, folder: Optional[Path], name: str, limit: int):
|
| 235 |
+
self.folder, self.name, self.limit = folder, name, limit
|
| 236 |
+
self.sha, self.size = hashlib.sha256(), 0
|
| 237 |
+
self.parts: list[dict] = []
|
| 238 |
+
self.fh = None
|
| 239 |
+
|
| 240 |
+
def write(self, b) -> int:
|
| 241 |
+
mv = memoryview(b).cast("B")
|
| 242 |
+
n = len(mv)
|
| 243 |
+
self.sha.update(mv)
|
| 244 |
+
self.size += n
|
| 245 |
+
while len(mv):
|
| 246 |
+
if not self.parts or self.parts[-1]["size"] >= self.limit:
|
| 247 |
+
self._next()
|
| 248 |
+
part = self.parts[-1]
|
| 249 |
+
take = mv[: self.limit - part["size"]]
|
| 250 |
+
part["sha"].update(take)
|
| 251 |
+
part["size"] += len(take)
|
| 252 |
+
if self.fh is not None:
|
| 253 |
+
self.fh.write(take)
|
| 254 |
+
mv = mv[len(take):]
|
| 255 |
+
return n
|
| 256 |
+
|
| 257 |
+
def _next(self) -> None:
|
| 258 |
+
if self.fh is not None:
|
| 259 |
+
self.fh.close()
|
| 260 |
+
name = f"{self.name}.part-{len(self.parts):03d}"
|
| 261 |
+
self.parts.append({"name": name, "size": 0, "sha": hashlib.sha256()})
|
| 262 |
+
self.fh = None if self.folder is None else open(self.folder / name, "xb") # never over a file
|
| 263 |
+
|
| 264 |
+
def flush(self) -> None:
|
| 265 |
+
if self.fh is not None:
|
| 266 |
+
self.fh.flush()
|
| 267 |
+
|
| 268 |
+
def close(self) -> dict:
|
| 269 |
+
if self.fh is not None:
|
| 270 |
+
self.fh.close()
|
| 271 |
+
self.fh = None
|
| 272 |
+
return {"size": self.size, "sha256": self.sha.hexdigest(), "parts": [p["name"] for p in self.parts],
|
| 273 |
+
"part_sha256": [p["sha"].hexdigest() for p in self.parts],
|
| 274 |
+
"part_sizes": [p["size"] for p in self.parts]}
|
| 275 |
+
|
| 276 |
+
|
| 277 |
+
def member_info(name: str, size: int, mtime: int) -> tarfile.TarInfo:
|
| 278 |
+
"""A regular-file header with nothing of the packing host in it: mode 0644, owner 0, no names, ``mtime``."""
|
| 279 |
+
ti = tarfile.TarInfo(name)
|
| 280 |
+
ti.size, ti.mtime, ti.mode, ti.type = size, mtime, 0o644, tarfile.REGTYPE
|
| 281 |
+
ti.uid = ti.gid = 0
|
| 282 |
+
ti.uname = ti.gname = ""
|
| 283 |
+
return ti
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
def plan_consolidate(tree: Path, drop_orphans: bool = False) -> dict:
|
| 287 |
+
"""Which file of the served ``tree`` goes in which archive (the module doc), the overlay, and the facts; every
|
| 288 |
+
refusal at once (SystemExit), before anything is written."""
|
| 289 |
+
try:
|
| 290 |
+
raw = (tree / "manifest.json").read_bytes()
|
| 291 |
+
doc = json.loads(raw)
|
| 292 |
+
except (OSError, ValueError) as exc:
|
| 293 |
+
raise SystemExit(f"{tree}: no readable manifest.json ({exc})") from None
|
| 294 |
+
if not isinstance(doc, dict) or doc.get("version") != 1 or not isinstance(doc.get("assets"), dict):
|
| 295 |
+
raise SystemExit(f"{tree}: not a library media manifest (version 1, assets{{}})")
|
| 296 |
+
problems: list[str] = []
|
| 297 |
+
usd1: dict[str, set[str]] = {}
|
| 298 |
+
usdhd: dict[str, set[str]] = {}
|
| 299 |
+
overlay: dict[str, dict] = {}
|
| 300 |
+
ids: set[str] = set()
|
| 301 |
+
named: dict[str, set[str]] = {} # every string the entries of an id give (their file names)
|
| 302 |
+
full = 0
|
| 303 |
+
for key, e in doc["assets"].items():
|
| 304 |
+
if not isinstance(e, dict) or not isinstance(e.get("id"), str) or not lm.SAFE_ID.match(e["id"]):
|
| 305 |
+
problems.append(f"{str(key)[-120:]}: no safe id")
|
| 306 |
+
continue
|
| 307 |
+
i = e["id"]
|
| 308 |
+
ids.add(i)
|
| 309 |
+
lm._strings(e, named.setdefault(i, set()))
|
| 310 |
+
files = e.get("files") if isinstance(e.get("files"), dict) else {}
|
| 311 |
+
if e.get("hd") is not None or any(files.get(r) for r in lm.GLB_ROLES):
|
| 312 |
+
problems.append(f"{i}: lists a GLB or a model_hd.glb (an entry kept on its GLB is never USD-only)")
|
| 313 |
+
continue
|
| 314 |
+
if "usd" not in e:
|
| 315 |
+
continue
|
| 316 |
+
if not lm.usd_block_ok(e["usd"]):
|
| 317 |
+
problems.append(f"{i}: a usd block the loader would not place (usd_block_ok)")
|
| 318 |
+
continue
|
| 319 |
+
overlay[key] = {"id": i, "usd": e["usd"]}
|
| 320 |
+
usd1.setdefault(i, set()).update(lm.usd_tier1_files(e["usd"]))
|
| 321 |
+
hd_names = lm.usd_hd_files(e["usd"])
|
| 322 |
+
usdhd.setdefault(i, set()).update(hd_names)
|
| 323 |
+
full += bool(hd_names)
|
| 324 |
+
hd = lm.hd_files(doc, models=False)
|
| 325 |
+
groups: dict[str, list[tuple[str, int]]] = {k: [] for k in KINDS}
|
| 326 |
+
found: set[tuple[str, str]] = set()
|
| 327 |
+
orphans: dict[str, list[int]] = {}
|
| 328 |
+
top: list[str] = []
|
| 329 |
+
for dirpath, dirnames, filenames in os.walk(tree):
|
| 330 |
+
dirnames.sort()
|
| 331 |
+
for d in dirnames:
|
| 332 |
+
if os.path.islink(Path(dirpath) / d):
|
| 333 |
+
problems.append(f"{(Path(dirpath) / d).relative_to(tree).as_posix()}: not a regular folder")
|
| 334 |
+
for fn in sorted(filenames):
|
| 335 |
+
p = Path(dirpath) / fn
|
| 336 |
+
rel = p.relative_to(tree).as_posix()
|
| 337 |
+
st = os.lstat(p)
|
| 338 |
+
if not stat.S_ISREG(st.st_mode):
|
| 339 |
+
problems.append(f"{rel}: not a regular file")
|
| 340 |
+
continue
|
| 341 |
+
if any(seg.startswith(".") for seg in rel.split("/")):
|
| 342 |
+
problems.append(f"{rel}: a dot-file (a .part left over?)")
|
| 343 |
+
continue
|
| 344 |
+
if rel == "manifest.json":
|
| 345 |
+
continue
|
| 346 |
+
where = lm.member_path(rel)
|
| 347 |
+
if where is None:
|
| 348 |
+
top.append(rel) # unpack writes it back; nothing names it
|
| 349 |
+
groups["main"].append((rel, st.st_size))
|
| 350 |
+
continue
|
| 351 |
+
i, path = where
|
| 352 |
+
if i not in ids:
|
| 353 |
+
orphans.setdefault(i, [0, 0])
|
| 354 |
+
orphans[i][0] += 1
|
| 355 |
+
orphans[i][1] += st.st_size
|
| 356 |
+
if drop_orphans:
|
| 357 |
+
continue
|
| 358 |
+
if path in GLB_FILES and path not in named.get(i, ()):
|
| 359 |
+
problems.append(f"{rel}: a GLB file no served block names (a USD-only stamp carries none"
|
| 360 |
+
+ ("; an orphan folder: --drop-orphans" if i not in ids else
|
| 361 |
+
"; a tree served with LIBRARY_MEDIA_GLB_SKIP=off?") + ")")
|
| 362 |
+
continue
|
| 363 |
+
found.add(where)
|
| 364 |
+
kind = ("usd" if path in usd1.get(i, ()) else "usdhd" if path in usdhd.get(i, ()) else
|
| 365 |
+
"hd" if path in hd.get(i, ()) else "main")
|
| 366 |
+
groups[kind].append((rel, st.st_size))
|
| 367 |
+
for kind, listed in (("usd", usd1), ("usdhd", usdhd)):
|
| 368 |
+
gone = sorted(f"{i}/{n}" for i, names in listed.items() for n in names if (i, n) not in found)
|
| 369 |
+
if gone:
|
| 370 |
+
problems.append(f"{len(gone)} files a served block names for the {kind} archive are not there "
|
| 371 |
+
f"(e.g. {', '.join(gone[:3])})")
|
| 372 |
+
if problems:
|
| 373 |
+
raise SystemExit(f"{tree}: not packed ({len(problems)} problems): " + "; ".join(problems[:MISSING_SHOWN])
|
| 374 |
+
+ (f"; +{len(problems) - MISSING_SHOWN} more" if len(problems) > MISSING_SHOWN else ""))
|
| 375 |
+
hd_missing = sorted(f"{i}/{n}" for i, names in hd.items() for n in names if (i, n) not in found)
|
| 376 |
+
for kind in KINDS:
|
| 377 |
+
groups[kind].sort()
|
| 378 |
+
overlay_raw = json.dumps({"version": 1, "profile": lm.USD_PROFILE, "assets": overlay},
|
| 379 |
+
separators=(",", ":")).encode()
|
| 380 |
+
return {
|
| 381 |
+
"manifest": raw, "overlay": overlay_raw, "groups": groups,
|
| 382 |
+
"facts": {
|
| 383 |
+
"entries": len(doc["assets"]), "usd_blocks": len(overlay), "usd_full": full,
|
| 384 |
+
"hd_listed": sum(len(names) for names in hd.values()), "hd_listed_missing": len(hd_missing),
|
| 385 |
+
"hd_missing_sample": hd_missing[:MISSING_SHOWN], "top_level_files": top,
|
| 386 |
+
"orphans": {"ids": len(orphans), "files": sum(n for n, _ in orphans.values()),
|
| 387 |
+
"bytes": sum(b for _, b in orphans.values()), "dropped": drop_orphans,
|
| 388 |
+
"sample": sorted(orphans)[:MISSING_SHOWN]},
|
| 389 |
+
"tree": {"files": 1 + sum(len(g) for g in groups.values()),
|
| 390 |
+
"bytes": len(raw) + sum(n for g in groups.values() for _, n in g)},
|
| 391 |
+
},
|
| 392 |
+
}
|
| 393 |
+
|
| 394 |
+
|
| 395 |
+
def write_archive(tree: Path, kind: str, members: list[tuple[str, int]], first: Optional[tuple[str, bytes]],
|
| 396 |
+
out: Optional[Path], name: str, part_size: int, mtime: int) -> dict:
|
| 397 |
+
"""One archive of the stamp: ``first`` (name, bytes) then ``members`` (paths below ``tree``, with the size each
|
| 398 |
+
had when planned: a file that changed since fails it), as ``out``/<part name>.part-NNN (``out`` None: hashed
|
| 399 |
+
only). Its facts for pack.json."""
|
| 400 |
+
sink = Parts(out, PART[kind], part_size)
|
| 401 |
+
with tarfile.open(fileobj=sink, mode="w|", format=tarfile.PAX_FORMAT, bufsize=BUFSIZE) as tf:
|
| 402 |
+
if first is not None:
|
| 403 |
+
tf.addfile(member_info(first[0], len(first[1]), mtime), _Bytes(first[1]))
|
| 404 |
+
for rel, size in members:
|
| 405 |
+
with open(tree / rel, "rb") as fh:
|
| 406 |
+
if os.fstat(fh.fileno()).st_size != size:
|
| 407 |
+
raise SystemExit(f"{tree / rel}: changed while packed ({size} B planned)")
|
| 408 |
+
tf.addfile(member_info(rel, size, mtime), fh)
|
| 409 |
+
facts = sink.close()
|
| 410 |
+
return {"name": name, **facts, "members": len(members) + (first is not None),
|
| 411 |
+
"files_bytes": sum(n for _, n in members) + (len(first[1]) if first else 0)}
|
| 412 |
+
|
| 413 |
+
|
| 414 |
+
class _Bytes:
|
| 415 |
+
"""A minimal reader over ``data`` for tarfile.addfile."""
|
| 416 |
+
|
| 417 |
+
def __init__(self, data: bytes):
|
| 418 |
+
self.data, self.pos = data, 0
|
| 419 |
+
|
| 420 |
+
def read(self, n: int = -1) -> bytes:
|
| 421 |
+
end = len(self.data) if n is None or n < 0 else self.pos + n
|
| 422 |
+
b = self.data[self.pos:end]
|
| 423 |
+
self.pos += len(b)
|
| 424 |
+
return b
|
| 425 |
+
|
| 426 |
+
|
| 427 |
+
def read_sources(path: Path, stamp: str) -> dict:
|
| 428 |
+
"""``--sources``: the run's ``library-media/`` listing when the served tree was made, ``{"run": <run id>,
|
| 429 |
+
"objects": {<name>: {"size": <bytes>, "sha256": <hex, optional>}}}`` (the folded stamp and adds among them).
|
| 430 |
+
Refused (SystemExit) when a name already uses ``stamp`` or a stamp in it is not older than ``stamp`` (the loader
|
| 431 |
+
serves only the newest). Returned as recorded in pack.json, with the main archives' stamps and the adds folded."""
|
| 432 |
+
try:
|
| 433 |
+
doc = json.loads(Path(path).read_text())
|
| 434 |
+
objects = doc["objects"]
|
| 435 |
+
if not isinstance(objects, dict) or not all(isinstance(n, str) and isinstance(o, dict)
|
| 436 |
+
and isinstance(o.get("size"), int) for n, o in objects.items()):
|
| 437 |
+
raise ValueError("objects: {name: {size, sha256?}}")
|
| 438 |
+
except (OSError, ValueError, KeyError, TypeError) as exc:
|
| 439 |
+
raise SystemExit(f"--sources {path}: unreadable ({exc})") from None
|
| 440 |
+
used = sorted(n for n in objects if n.startswith(f"media-{stamp}."))
|
| 441 |
+
if used:
|
| 442 |
+
raise SystemExit(f"--stamp {stamp} is already used in the run: {', '.join(used[:MISSING_SHOWN])}")
|
| 443 |
+
stamps = sorted({m.group(1) for n in objects if (m := MAIN_ARCHIVE.match(n))})
|
| 444 |
+
if stamps and stamps[-1] >= stamp:
|
| 445 |
+
raise SystemExit(f"--stamp {stamp} is not newer than the run's newest stamp {stamps[-1]}: never served")
|
| 446 |
+
adds = sorted(n for n in objects if ADD_OBJECT.match(n))
|
| 447 |
+
return {"run": doc.get("run"), "stamps": stamps, "adds": adds,
|
| 448 |
+
"objects": {n: objects[n] for n in sorted(objects)}}
|
| 449 |
+
|
| 450 |
+
|
| 451 |
+
def consolidate(a: argparse.Namespace) -> int:
|
| 452 |
+
if not STAMP.match(a.stamp):
|
| 453 |
+
raise SystemExit(f"--stamp {a.stamp!r} is not YYYYMMDDTHHMMZ")
|
| 454 |
+
if a.part_size < tarfile.BLOCKSIZE:
|
| 455 |
+
raise SystemExit(f"--part-size under {tarfile.BLOCKSIZE} B")
|
| 456 |
+
sources = read_sources(a.sources, a.stamp) if a.sources else None
|
| 457 |
+
if sources is None:
|
| 458 |
+
print("library_media_pack: no --sources β pack.json records no run listing, so the upload cannot check that "
|
| 459 |
+
"every add was folded", file=sys.stderr)
|
| 460 |
+
plan = plan_consolidate(a.tree, a.drop_orphans)
|
| 461 |
+
mtime = calendar.timegm(time.strptime(a.stamp, "%Y%m%dT%H%MZ"))
|
| 462 |
+
a.out.mkdir(parents=True) # never into an existing folder
|
| 463 |
+
try:
|
| 464 |
+
firsts = {"main": ("manifest.json", plan["manifest"]), "usd": (lm.USD_OVERLAY, plan["overlay"])}
|
| 465 |
+
args = [(a.tree, kind, plan["groups"][kind], firsts.get(kind), None if a.hash_only else a.out,
|
| 466 |
+
f"media-{a.stamp}{SUFFIX[kind]}", a.part_size, mtime) for kind in KINDS]
|
| 467 |
+
if a.jobs <= 1:
|
| 468 |
+
archives = {kind: write_archive(*arg) for kind, arg in zip(KINDS, args)}
|
| 469 |
+
else:
|
| 470 |
+
with ProcessPoolExecutor(min(a.jobs, len(KINDS))) as ex:
|
| 471 |
+
archives = dict(zip(KINDS, [j.result() for j in [ex.submit(write_archive, *arg) for arg in args]]))
|
| 472 |
+
pack = {
|
| 473 |
+
"kind": "usd-only stamp", "stamp": a.stamp, "run": a.run, "profile": lm.USD_PROFILE,
|
| 474 |
+
**archives,
|
| 475 |
+
"manifest_sha256": hashlib.sha256(plan["manifest"]).hexdigest(),
|
| 476 |
+
"overlay_sha256": hashlib.sha256(plan["overlay"]).hexdigest(),
|
| 477 |
+
"mtime": mtime, "part_size": a.part_size, "hash_only": a.hash_only,
|
| 478 |
+
"loader": "pipeline/library_media.py with USD_ONLY (a USD-only stamp, under GLB off only)",
|
| 479 |
+
"python": platform.python_version(), "sources": sources,
|
| 480 |
+
**plan["facts"],
|
| 481 |
+
}
|
| 482 |
+
(a.out / "pack.json").write_text(json.dumps(pack, indent=1) + "\n")
|
| 483 |
+
if not a.hash_only:
|
| 484 |
+
sums = sorted((p, s) for k in KINDS for p, s in zip(archives[k]["parts"], archives[k]["part_sha256"]))
|
| 485 |
+
(a.out / "SHA256SUMS").write_text("".join(f"{s} {p}\n" for p, s in sums))
|
| 486 |
+
except BaseException:
|
| 487 |
+
shutil.rmtree(a.out, ignore_errors=True)
|
| 488 |
+
raise
|
| 489 |
+
print(json.dumps({k: (pack[k] if k not in KINDS else {f: pack[k][f] for f in ("name", "size", "sha256", "members")})
|
| 490 |
+
for k in ("stamp", *KINDS, "entries", "usd_blocks", "usd_full", "hd_listed",
|
| 491 |
+
"hd_listed_missing", "orphans", "tree")}, indent=1))
|
| 492 |
+
return 0
|
| 493 |
+
|
| 494 |
+
|
| 495 |
+
def main(argv=None) -> int:
|
| 496 |
+
ap = argparse.ArgumentParser(prog="python -m pipeline.library_media_pack",
|
| 497 |
+
description="Pack library media archives (see the module doc).")
|
| 498 |
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
| 499 |
+
p = sub.add_parser("hd-noglb", help="a stamp's .hd-noglb.tar from its .hd.tar and manifest")
|
| 500 |
+
p.add_argument("--manifest", type=Path, required=True, help="manifest.json, or the stamp's media-<stamp>.tar")
|
| 501 |
+
p.add_argument("--in", dest="src", default="-", help="the .hd.tar (default: stdin)")
|
| 502 |
+
p.add_argument("--out", default="-", help="the .hd-noglb.tar to write, never over a file (default: stdout)")
|
| 503 |
+
p.add_argument("--summary", type=Path, help="write the summary JSON here (default: stderr)")
|
| 504 |
+
p.add_argument("--require-complete", action="store_true",
|
| 505 |
+
help="exit 2 when a file the manifest lists is not in the source")
|
| 506 |
+
c = sub.add_parser("consolidate", help="a served tree (GLB off) as ONE USD-only stamp")
|
| 507 |
+
c.add_argument("--tree", type=Path, required=True, help="the served tree (.build/library_media/usdsearch-dev)")
|
| 508 |
+
c.add_argument("--stamp", required=True, help="the new stamp, YYYYMMDDTHHMMZ (UTC)")
|
| 509 |
+
c.add_argument("--out", type=Path, required=True, help="a folder that does not exist yet")
|
| 510 |
+
c.add_argument("--run", default=None, help="the index run it is for (recorded in pack.json)")
|
| 511 |
+
c.add_argument("--part-size", type=int, default=PART_SIZE)
|
| 512 |
+
c.add_argument("--jobs", type=int, default=4, help="archives written at once (default 4)")
|
| 513 |
+
c.add_argument("--hash-only", action="store_true", help="no part written: pack.json with sizes and sha256s")
|
| 514 |
+
c.add_argument("--drop-orphans", action="store_true", help="leave out the folders of ids no entry carries")
|
| 515 |
+
c.add_argument("--sources", type=Path, default=None,
|
| 516 |
+
help="the run's library-media listing the tree was served from (JSON; recorded in pack.json)")
|
| 517 |
+
a = ap.parse_args(argv)
|
| 518 |
+
return hd_noglb(a) if a.cmd == "hd-noglb" else consolidate(a)
|
| 519 |
+
|
| 520 |
+
|
| 521 |
+
if __name__ == "__main__":
|
| 522 |
+
sys.exit(main())
|
pipeline/package_download/app.py
CHANGED
|
@@ -22,7 +22,7 @@ from fastapi import FastAPI, Query, Request
|
|
| 22 |
from fastapi.responses import FileResponse, JSONResponse, Response, StreamingResponse
|
| 23 |
from starlette.background import BackgroundTask
|
| 24 |
|
| 25 |
-
from . import bundle, telemetry
|
| 26 |
from .catalog import Catalog
|
| 27 |
from .settings import Settings
|
| 28 |
from .sizes import PackageSizes
|
|
@@ -336,6 +336,14 @@ def create_app(
|
|
| 336 |
if manifest_only:
|
| 337 |
return JSONResponse(public(bundle.preview(asset_url, package, listed)))
|
| 338 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 339 |
if settings.max_bundle_bytes > 0 and total > settings.max_bundle_bytes:
|
| 340 |
raise _HTTPError(
|
| 341 |
413, f"Download bundle is too large ({total} bytes; limit is {settings.max_bundle_bytes})."
|
|
@@ -360,6 +368,7 @@ def create_app(
|
|
| 360 |
read_ahead_bytes=settings.read_ahead_bytes,
|
| 361 |
connections=connections,
|
| 362 |
retry=retry,
|
|
|
|
| 363 |
)
|
| 364 |
except bundle.RootMissing as exc:
|
| 365 |
raise _HTTPError(404, f"Asset '{to_public_url(asset_url)}' could not be retrieved: {exc}")
|
|
|
|
| 22 |
from fastapi.responses import FileResponse, JSONResponse, Response, StreamingResponse
|
| 23 |
from starlette.background import BackgroundTask
|
| 24 |
|
| 25 |
+
from . import bundle, dedupe, telemetry
|
| 26 |
from .catalog import Catalog
|
| 27 |
from .settings import Settings
|
| 28 |
from .sizes import PackageSizes
|
|
|
|
| 336 |
if manifest_only:
|
| 337 |
return JSONResponse(public(bundle.preview(asset_url, package, listed)))
|
| 338 |
|
| 339 |
+
shared: dict = {}
|
| 340 |
+
if settings.dedupe:
|
| 341 |
+
try:
|
| 342 |
+
shared = await dedupe.plan(package, listed, source.read)
|
| 343 |
+
except Exception as exc: # noqa: BLE001 β best effort: every file is fetched on its own
|
| 344 |
+
logger.warning("shared fetches for %s skipped: %s", package.root_address, exc)
|
| 345 |
+
stats["n_shared"] = sum(len(s.copies) for s in shared.values())
|
| 346 |
+
|
| 347 |
if settings.max_bundle_bytes > 0 and total > settings.max_bundle_bytes:
|
| 348 |
raise _HTTPError(
|
| 349 |
413, f"Download bundle is too large ({total} bytes; limit is {settings.max_bundle_bytes})."
|
|
|
|
| 368 |
read_ahead_bytes=settings.read_ahead_bytes,
|
| 369 |
connections=connections,
|
| 370 |
retry=retry,
|
| 371 |
+
shared=shared,
|
| 372 |
)
|
| 373 |
except bundle.RootMissing as exc:
|
| 374 |
raise _HTTPError(404, f"Asset '{to_public_url(asset_url)}' could not be retrieved: {exc}")
|
pipeline/package_download/bundle.py
CHANGED
|
@@ -12,6 +12,7 @@ from __future__ import annotations
|
|
| 12 |
|
| 13 |
import asyncio
|
| 14 |
import contextlib
|
|
|
|
| 15 |
import json
|
| 16 |
import logging
|
| 17 |
import math
|
|
@@ -25,7 +26,7 @@ import zipfile
|
|
| 25 |
from collections import deque
|
| 26 |
from concurrent.futures import ThreadPoolExecutor
|
| 27 |
from dataclasses import dataclass, field
|
| 28 |
-
from typing import AsyncIterator, Awaitable, Callable, Deque, Dict, List, Optional, Tuple, TypeVar
|
| 29 |
|
| 30 |
from .catalog import Package
|
| 31 |
|
|
@@ -313,6 +314,7 @@ async def build_zip(
|
|
| 313 |
read_ahead_bytes: int = 0,
|
| 314 |
connections: Optional[asyncio.Semaphore] = None,
|
| 315 |
retry: RetryPolicy = NO_RETRY,
|
|
|
|
| 316 |
) -> BundleResult:
|
| 317 |
"""Fetch every listed file (bounded concurrency) and write it into a ZIP at ``zip_path``.
|
| 318 |
|
|
@@ -330,6 +332,10 @@ async def build_zip(
|
|
| 330 |
backoff β a ``connections`` slot (reads in flight across every bundle). A transient
|
| 331 |
failure is retried per ``retry`` from byte 0: nothing reaches the archive before a
|
| 332 |
file is whole. Cancelled, it stops every read and the writer before it returns.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 333 |
"""
|
| 334 |
result = BundleResult()
|
| 335 |
semaphore = asyncio.Semaphore(max(concurrency, 1))
|
|
@@ -339,15 +345,23 @@ async def build_zip(
|
|
| 339 |
in_ram = max(min(spool_bytes, read_ahead_bytes) if read_ahead_bytes > 0 else spool_bytes, 1)
|
| 340 |
mode = compression.strip().lower()
|
| 341 |
started = time.perf_counter()
|
|
|
|
|
|
|
|
|
|
| 342 |
|
| 343 |
async def download(entry: FileEntry, spool) -> int:
|
| 344 |
async def attempt() -> int:
|
| 345 |
spool.seek(0)
|
| 346 |
spool.truncate() # a retry starts over: nothing of a failed try is kept
|
|
|
|
| 347 |
async with limited(connections):
|
| 348 |
async with contextlib.aclosing(read(package, entry.path)) as chunks:
|
| 349 |
async for chunk in chunks:
|
| 350 |
spool.write(chunk)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 351 |
return spool.tell()
|
| 352 |
|
| 353 |
return await retrying(attempt, retry, package.address(entry.path))
|
|
@@ -372,37 +386,63 @@ async def build_zip(
|
|
| 372 |
spool.seek(0)
|
| 373 |
return entry, spool, size, None, cost
|
| 374 |
|
| 375 |
-
tasks
|
| 376 |
stop = threading.Event() # tells the writer thread to let go of the member it is copying
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 377 |
try:
|
| 378 |
with ThreadPoolExecutor(max_workers=1, thread_name_prefix="package-zip") as writer:
|
| 379 |
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED, allowZip64=True) as zf:
|
| 380 |
-
for
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
if isinstance(exc, SourceError) and exc.kind in ("denied", "busy", "unavailable"):
|
| 385 |
-
raise exc # the backend itself is down; a ZIP of holes helps nobody
|
| 386 |
-
if entry.path == package.asset_path:
|
| 387 |
-
raise RootMissing(str(exc) or "not found")
|
| 388 |
-
result.errors[address] = str(exc) or type(exc).__name__
|
| 389 |
-
continue
|
| 390 |
-
try:
|
| 391 |
-
with spool:
|
| 392 |
-
result.total_bytes += size
|
| 393 |
-
if max_bytes > 0 and result.total_bytes > max_bytes:
|
| 394 |
-
raise BundleTooLarge(f"Download bundle exceeded the size limit of {max_bytes} bytes.")
|
| 395 |
-
arc = safe_arcname(package, entry.path)
|
| 396 |
-
if arc is None:
|
| 397 |
-
result.errors[address] = "path outside the package"
|
| 398 |
-
continue
|
| 399 |
-
info = zipfile.ZipInfo(arc, date_time=time.localtime()[:6])
|
| 400 |
-
info.compress_type = _compress_type(arc, mode, stored_extensions)
|
| 401 |
-
info.external_attr = 0o644 << 16
|
| 402 |
-
await _write_member(writer, stop, zf, info, spool, size)
|
| 403 |
-
result.files.append({"url": address, "path": arc, "size": size})
|
| 404 |
-
finally:
|
| 405 |
-
budget.release(cost)
|
| 406 |
finally:
|
| 407 |
for task in tasks:
|
| 408 |
task.cancel()
|
|
|
|
| 12 |
|
| 13 |
import asyncio
|
| 14 |
import contextlib
|
| 15 |
+
import hashlib
|
| 16 |
import json
|
| 17 |
import logging
|
| 18 |
import math
|
|
|
|
| 26 |
from collections import deque
|
| 27 |
from concurrent.futures import ThreadPoolExecutor
|
| 28 |
from dataclasses import dataclass, field
|
| 29 |
+
from typing import Any, AsyncIterator, Awaitable, Callable, Deque, Dict, List, Optional, Tuple, TypeVar
|
| 30 |
|
| 31 |
from .catalog import Package
|
| 32 |
|
|
|
|
| 314 |
read_ahead_bytes: int = 0,
|
| 315 |
connections: Optional[asyncio.Semaphore] = None,
|
| 316 |
retry: RetryPolicy = NO_RETRY,
|
| 317 |
+
shared: Optional[Dict[str, Any]] = None,
|
| 318 |
) -> BundleResult:
|
| 319 |
"""Fetch every listed file (bounded concurrency) and write it into a ZIP at ``zip_path``.
|
| 320 |
|
|
|
|
| 332 |
backoff β a ``connections`` slot (reads in flight across every bundle). A transient
|
| 333 |
failure is retried per ``retry`` from byte 0: nothing reaches the archive before a
|
| 334 |
file is whole. Cancelled, it stops every read and the writer before it returns.
|
| 335 |
+
|
| 336 |
+
``shared`` (dedupe.plan: path -> Shared(sha256, copies)) fetches a file once for every
|
| 337 |
+
path that holds the same bytes: its copies are written from its spool, after its
|
| 338 |
+
sha256 is checked. A fetch that fails or does not match fetches each copy on its own.
|
| 339 |
"""
|
| 340 |
result = BundleResult()
|
| 341 |
semaphore = asyncio.Semaphore(max(concurrency, 1))
|
|
|
|
| 345 |
in_ram = max(min(spool_bytes, read_ahead_bytes) if read_ahead_bytes > 0 else spool_bytes, 1)
|
| 346 |
mode = compression.strip().lower()
|
| 347 |
started = time.perf_counter()
|
| 348 |
+
shared = shared or {}
|
| 349 |
+
copied = {c.path for s in shared.values() for c in s.copies}
|
| 350 |
+
digests: Dict[str, str] = {} # sha256 of a shared fetch, checked before its copies are written
|
| 351 |
|
| 352 |
async def download(entry: FileEntry, spool) -> int:
|
| 353 |
async def attempt() -> int:
|
| 354 |
spool.seek(0)
|
| 355 |
spool.truncate() # a retry starts over: nothing of a failed try is kept
|
| 356 |
+
digest = hashlib.sha256() if entry.path in shared else None
|
| 357 |
async with limited(connections):
|
| 358 |
async with contextlib.aclosing(read(package, entry.path)) as chunks:
|
| 359 |
async for chunk in chunks:
|
| 360 |
spool.write(chunk)
|
| 361 |
+
if digest is not None:
|
| 362 |
+
digest.update(chunk)
|
| 363 |
+
if digest is not None:
|
| 364 |
+
digests[entry.path] = digest.hexdigest()
|
| 365 |
return spool.tell()
|
| 366 |
|
| 367 |
return await retrying(attempt, retry, package.address(entry.path))
|
|
|
|
| 386 |
spool.seek(0)
|
| 387 |
return entry, spool, size, None, cost
|
| 388 |
|
| 389 |
+
tasks: List[asyncio.Task] = []
|
| 390 |
stop = threading.Event() # tells the writer thread to let go of the member it is copying
|
| 391 |
+
|
| 392 |
+
async def write(writer, zf, entry: FileEntry, spool, size: int) -> None:
|
| 393 |
+
address = package.address(entry.path)
|
| 394 |
+
result.total_bytes += size
|
| 395 |
+
if max_bytes > 0 and result.total_bytes > max_bytes:
|
| 396 |
+
raise BundleTooLarge(f"Download bundle exceeded the size limit of {max_bytes} bytes.")
|
| 397 |
+
arc = safe_arcname(package, entry.path)
|
| 398 |
+
if arc is None:
|
| 399 |
+
result.errors[address] = "path outside the package"
|
| 400 |
+
return
|
| 401 |
+
info = zipfile.ZipInfo(arc, date_time=time.localtime()[:6])
|
| 402 |
+
info.compress_type = _compress_type(arc, mode, stored_extensions)
|
| 403 |
+
info.external_attr = 0o644 << 16
|
| 404 |
+
await _write_member(writer, stop, zf, info, spool, size)
|
| 405 |
+
result.files.append({"url": address, "path": arc, "size": size})
|
| 406 |
+
|
| 407 |
+
async def run(writer, zf, entries: List[FileEntry]) -> List[FileEntry]:
|
| 408 |
+
"""Fetch and write ``entries``; returns the copies whose shared fetch failed or did not match."""
|
| 409 |
+
batch = [asyncio.create_task(fetch(f)) for f in entries]
|
| 410 |
+
tasks.extend(batch)
|
| 411 |
+
again: List[FileEntry] = []
|
| 412 |
+
for completed in asyncio.as_completed(batch):
|
| 413 |
+
entry, spool, size, exc, cost = await completed
|
| 414 |
+
group = shared.get(entry.path)
|
| 415 |
+
if spool is None:
|
| 416 |
+
if isinstance(exc, SourceError) and exc.kind in ("denied", "busy", "unavailable"):
|
| 417 |
+
raise exc # the backend itself is down; a ZIP of holes helps nobody
|
| 418 |
+
if entry.path == package.asset_path:
|
| 419 |
+
raise RootMissing(str(exc) or "not found")
|
| 420 |
+
result.errors[package.address(entry.path)] = str(exc) or type(exc).__name__
|
| 421 |
+
again.extend(group.copies if group else ())
|
| 422 |
+
continue
|
| 423 |
+
try:
|
| 424 |
+
with spool:
|
| 425 |
+
await write(writer, zf, entry, spool, size)
|
| 426 |
+
if group:
|
| 427 |
+
if digests.get(entry.path) != group.sha256:
|
| 428 |
+
logger.warning("%s does not match its BOM sha256: its copies are fetched on their own",
|
| 429 |
+
package.address(entry.path))
|
| 430 |
+
again.extend(group.copies)
|
| 431 |
+
continue
|
| 432 |
+
for copy in group.copies:
|
| 433 |
+
spool.seek(0)
|
| 434 |
+
await write(writer, zf, copy, spool, size)
|
| 435 |
+
finally:
|
| 436 |
+
budget.release(cost)
|
| 437 |
+
return again
|
| 438 |
+
|
| 439 |
try:
|
| 440 |
with ThreadPoolExecutor(max_workers=1, thread_name_prefix="package-zip") as writer:
|
| 441 |
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED, allowZip64=True) as zf:
|
| 442 |
+
again = await run(writer, zf, [f for f in files if f.path not in copied])
|
| 443 |
+
if again:
|
| 444 |
+
# Each on its own, as without a BOM; a copy is never itself shared.
|
| 445 |
+
await run(writer, zf, again)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 446 |
finally:
|
| 447 |
for task in tasks:
|
| 448 |
task.cancel()
|
pipeline/package_download/dedupe.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Byte-identical files of a package, fetched from the source once.
|
| 2 |
+
|
| 3 |
+
SimReady Central product review 2026-10-14 (OMPE-116021, download speed): every package in
|
| 4 |
+
``nvidia/simready-assets`` ships its textures twice, under ``simready_usd/Textures/`` (what the USD
|
| 5 |
+
references) and as byte-identical copies under ``simready_usd/source_assets/``: about half of a
|
| 6 |
+
package's bytes (``rack_composition_l09``: 238 + 238 of 599 MB; ``robotic_system_console_b01``:
|
| 7 |
+
981 + 981 of 2,026 MB). The package's BOM (``.metadata/com.nvidia.simready.packaging.bom.json``,
|
| 8 |
+
every file with its sha256 and size) tells which.
|
| 9 |
+
|
| 10 |
+
The download keeps every file: the BOM lists the copies, so a package without them would fail its
|
| 11 |
+
own validation. What changes is how often the bytes are read: the files the BOM gives one sha256 and
|
| 12 |
+
one size, and the listing that same size, are fetched once and written into the archive under every
|
| 13 |
+
path that has them (``bundle.build_zip``, which checks the fetched bytes against the sha256 first and
|
| 14 |
+
fetches the other paths on their own when they differ). No BOM, a malformed one, or a file it does not
|
| 15 |
+
cover: fetched as before.
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
import json
|
| 21 |
+
import logging
|
| 22 |
+
from dataclasses import dataclass, field
|
| 23 |
+
from typing import AsyncIterator, Callable, Dict, List, Tuple
|
| 24 |
+
|
| 25 |
+
from .bundle import FileEntry
|
| 26 |
+
from .catalog import Package
|
| 27 |
+
|
| 28 |
+
logger = logging.getLogger("package_download")
|
| 29 |
+
|
| 30 |
+
BOM_PATH = ".metadata/com.nvidia.simready.packaging.bom.json"
|
| 31 |
+
SOURCE_DIR = "source_assets"
|
| 32 |
+
MAX_BOM = 16 * 1024 * 1024
|
| 33 |
+
|
| 34 |
+
Reader = Callable[[Package, str], AsyncIterator[bytes]]
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
@dataclass
|
| 38 |
+
class Shared:
|
| 39 |
+
"""One fetch for several paths: the BOM's sha256 of the bytes, and the other files that hold them."""
|
| 40 |
+
|
| 41 |
+
sha256: str
|
| 42 |
+
copies: List[FileEntry] = field(default_factory=list)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def bom_items(doc) -> Dict[str, Tuple[str, int]]:
|
| 46 |
+
"""Package-relative path -> (sha256, size) from a BOM document; {} when it has none."""
|
| 47 |
+
out: Dict[str, Tuple[str, int]] = {}
|
| 48 |
+
items = doc.get("items") if isinstance(doc, dict) else None
|
| 49 |
+
for item in items if isinstance(items, list) else ():
|
| 50 |
+
if not isinstance(item, dict):
|
| 51 |
+
continue
|
| 52 |
+
path, digest, size = item.get("relative_path"), (item.get("hash") or {}).get("sha256"), item.get("size")
|
| 53 |
+
if isinstance(path, str) and isinstance(digest, str) and path and digest and isinstance(size, int):
|
| 54 |
+
out[path] = (digest.lower(), size)
|
| 55 |
+
return out
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def under_source(rel: str) -> bool:
|
| 59 |
+
return SOURCE_DIR in rel.split("/")[:-1]
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def groups(package: Package, listed: List[FileEntry], items: Dict[str, Tuple[str, int]]) -> Dict[str, Shared]:
|
| 63 |
+
"""Path of the file fetched -> Shared, for every BOM sha256 that two or more listed files hold.
|
| 64 |
+
|
| 65 |
+
A file counts only when the listing's size equals the BOM's. The one fetched is the first outside
|
| 66 |
+
any ``source_assets/`` folder (what the USD references), else the first by path; the root asset is
|
| 67 |
+
always fetched on its own.
|
| 68 |
+
"""
|
| 69 |
+
by_hash: Dict[Tuple[str, int], List[FileEntry]] = {}
|
| 70 |
+
for f in listed:
|
| 71 |
+
hit = items.get(package.relative(f.path))
|
| 72 |
+
if hit and hit[1] == f.size and f.path != package.asset_path:
|
| 73 |
+
by_hash.setdefault(hit, []).append(f)
|
| 74 |
+
out: Dict[str, Shared] = {}
|
| 75 |
+
for (digest, _), files in by_hash.items():
|
| 76 |
+
if len(files) < 2:
|
| 77 |
+
continue
|
| 78 |
+
files = sorted(files, key=lambda f: (under_source(package.relative(f.path)), f.path))
|
| 79 |
+
out[files[0].path] = Shared(digest, files[1:])
|
| 80 |
+
return out
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
async def _read_all(read: Reader, package: Package, path: str, limit: int) -> bytes:
|
| 84 |
+
data = bytearray()
|
| 85 |
+
async for chunk in read(package, path):
|
| 86 |
+
data += chunk
|
| 87 |
+
if len(data) > limit:
|
| 88 |
+
raise ValueError(f"{path} is over {limit} bytes")
|
| 89 |
+
return bytes(data)
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
async def plan(package: Package, listed: List[FileEntry], read: Reader) -> Dict[str, Shared]:
|
| 93 |
+
"""The shared fetches of a package (one read: its BOM); {} without a usable BOM."""
|
| 94 |
+
bom = next((f for f in listed if package.relative(f.path) == BOM_PATH), None)
|
| 95 |
+
if bom is None or bom.size > MAX_BOM:
|
| 96 |
+
return {}
|
| 97 |
+
return groups(package, listed, bom_items(json.loads(await _read_all(read, package, bom.path, MAX_BOM))))
|
pipeline/package_download/settings.py
CHANGED
|
@@ -67,6 +67,9 @@ class Settings:
|
|
| 67 |
read_timeout: float = 300.0
|
| 68 |
# Read timeout for the forwarded deepsearch-api request (it bundles, then answers).
|
| 69 |
fallback_timeout: float = 300.0
|
|
|
|
|
|
|
|
|
|
| 70 |
|
| 71 |
@classmethod
|
| 72 |
def from_env(cls, env: Optional[Mapping[str, str]] = None) -> "Settings":
|
|
|
|
| 67 |
read_timeout: float = 300.0
|
| 68 |
# Read timeout for the forwarded deepsearch-api request (it bundles, then answers).
|
| 69 |
fallback_timeout: float = 300.0
|
| 70 |
+
# Fetch the files the package's BOM gives one sha256 once, written under every path that
|
| 71 |
+
# has them (dedupe.py): about half the reads of a simready-assets download. Every file ships.
|
| 72 |
+
dedupe: bool = True
|
| 73 |
|
| 74 |
@classmethod
|
| 75 |
def from_env(cls, env: Optional[Mapping[str, str]] = None) -> "Settings":
|
pipeline/package_download/sizes.py
CHANGED
|
@@ -75,6 +75,9 @@ class SizesSettings:
|
|
| 75 |
relist_after: float = 6 * 3600.0
|
| 76 |
# Outside .build/restore/, which the index restore empties at every start.
|
| 77 |
cache_dir: Path = Path("/app/hf-space-pipeline/.build/package_sizes")
|
|
|
|
|
|
|
|
|
|
| 78 |
|
| 79 |
@classmethod
|
| 80 |
def from_env(cls, env: Optional[Mapping[str, str]] = None) -> "SizesSettings":
|
|
@@ -189,6 +192,7 @@ class PackageSizes:
|
|
| 189 |
self._walked = False
|
| 190 |
self._sums: Dict[Group, Sums] = {}
|
| 191 |
self._refused: Dict[Group, float] = {} # group -> wall time it may be asked again
|
|
|
|
| 192 |
self._next_hf = 0.0
|
| 193 |
self._task: Optional[asyncio.Task] = None
|
| 194 |
self._search: Optional[httpx.AsyncClient] = None
|
|
@@ -284,6 +288,9 @@ class PackageSizes:
|
|
| 284 |
logger.info("package sizes: index not readable yet (%s); next try in %.0f s", exc, s.retry_interval)
|
| 285 |
return s.retry_interval
|
| 286 |
self._keys, self._walked = keys, True
|
|
|
|
|
|
|
|
|
|
| 287 |
now = self._wall()
|
| 288 |
self._refused = {g: t for g, t in self._refused.items() if t > now}
|
| 289 |
counts = Counter(v[0] for v in keys.values() if v)
|
|
@@ -445,6 +452,30 @@ class PackageSizes:
|
|
| 445 |
logger.info("package sizes: %d group(s) from %s", loaded, folder)
|
| 446 |
return loaded
|
| 447 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 448 |
def _write_cache(self, group: Group, sums: Sums) -> None:
|
| 449 |
folder = self._groups_dir()
|
| 450 |
try:
|
|
|
|
| 75 |
relist_after: float = 6 * 3600.0
|
| 76 |
# Outside .build/restore/, which the index restore empties at every start.
|
| 77 |
cache_dir: Path = Path("/app/hf-space-pipeline/.build/package_sizes")
|
| 78 |
+
# The run's own sums (enumerate's listing, shipped by export): read once the index is readable, i.e. once the
|
| 79 |
+
# seed has downloaded the run. A run without one (before 2026-10-19) is listed from HF as before.
|
| 80 |
+
precomputed: Path = Path("/app/hf-space-pipeline/.build/restore/package_sizes.json")
|
| 81 |
|
| 82 |
@classmethod
|
| 83 |
def from_env(cls, env: Optional[Mapping[str, str]] = None) -> "SizesSettings":
|
|
|
|
| 192 |
self._walked = False
|
| 193 |
self._sums: Dict[Group, Sums] = {}
|
| 194 |
self._refused: Dict[Group, float] = {} # group -> wall time it may be asked again
|
| 195 |
+
self._precomputed_loaded = False
|
| 196 |
self._next_hf = 0.0
|
| 197 |
self._task: Optional[asyncio.Task] = None
|
| 198 |
self._search: Optional[httpx.AsyncClient] = None
|
|
|
|
| 288 |
logger.info("package sizes: index not readable yet (%s); next try in %.0f s", exc, s.retry_interval)
|
| 289 |
return s.retry_interval
|
| 290 |
self._keys, self._walked = keys, True
|
| 291 |
+
if not self._precomputed_loaded:
|
| 292 |
+
self._precomputed_loaded = True
|
| 293 |
+
self.load_precomputed()
|
| 294 |
now = self._wall()
|
| 295 |
self._refused = {g: t for g, t in self._refused.items() if t > now}
|
| 296 |
counts = Counter(v[0] for v in keys.values() if v)
|
|
|
|
| 452 |
logger.info("package sizes: %d group(s) from %s", loaded, folder)
|
| 453 |
return loaded
|
| 454 |
|
| 455 |
+
def load_precomputed(self) -> int:
|
| 456 |
+
"""The run's own sums (pipeline/enumerate_assets.py's write_package_sizes): every group they cover needs no
|
| 457 |
+
HF listing. Anything unreadable leaves the listing to do it, group by group."""
|
| 458 |
+
path = Path(self.settings.precomputed)
|
| 459 |
+
try:
|
| 460 |
+
groups = json.loads(path.read_text())["groups"]
|
| 461 |
+
except FileNotFoundError:
|
| 462 |
+
return 0
|
| 463 |
+
except (OSError, ValueError, KeyError, TypeError) as exc:
|
| 464 |
+
logger.warning("package sizes: ignoring %s (%s)", path, exc)
|
| 465 |
+
return 0
|
| 466 |
+
loaded, now = 0, self._wall()
|
| 467 |
+
for doc in groups if isinstance(groups, list) else []:
|
| 468 |
+
try:
|
| 469 |
+
group = Group(str(doc["kind"]), str(doc["repo_id"]), str(doc["revision"]))
|
| 470 |
+
self._sums[group] = Sums(
|
| 471 |
+
int(doc["whole"]), {str(k): int(v) for k, v in doc["folders"].items()}, int(doc["files"]), now,
|
| 472 |
+
)
|
| 473 |
+
loaded += 1
|
| 474 |
+
except (ValueError, KeyError, TypeError, AttributeError) as exc:
|
| 475 |
+
logger.warning("package sizes: ignoring a group in %s (%s)", path.name, exc)
|
| 476 |
+
logger.info("package sizes: %d group(s) precomputed by the index run (%s)", loaded, path)
|
| 477 |
+
return loaded
|
| 478 |
+
|
| 479 |
def _write_cache(self, group: Group, sums: Sums) -> None:
|
| 480 |
folder = self._groups_dir()
|
| 481 |
try:
|
pipeline/profiles.py
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Stage `profiles` β each asset's SimReady validation profile, from its package's validation sidecar.
|
| 2 |
+
|
| 3 |
+
The Space's Profiles filter lists the values of `simready_metadata_validation__profile`. USD Search indexes that
|
| 4 |
+
property only from the root USD's `SimReady_Metadata.validation` block, which NVIDIA's and LG's packages author.
|
| 5 |
+
Packages made with `simready-manage submit` record the profile in `<package>/.metadata/<package>_validation.json`
|
| 6 |
+
instead (validation never edits the USD), so Robotiq's grippers (Robot-Gripper) and Standard Bots' arms (Robot-Body)
|
| 7 |
+
reached the index with no profile at all. Between `categories` and `export`, this stage reads that sidecar at the
|
| 8 |
+
package's pinned revision for every partner (non-NVIDIA) package and every asset still without a profile, and writes
|
| 9 |
+
`simready_metadata_validation__profile` and `..._profile_version` from it:
|
| 10 |
+
|
| 11 |
+
* the sidecar wins where the USD says something else (it is the validation record); the stage lists every such
|
| 12 |
+
disagreement;
|
| 13 |
+
* a package without a sidecar (404) keeps whatever its USD says;
|
| 14 |
+
* NVIDIA packages whose USD carries a profile are not read (their packages have no sidecar).
|
| 15 |
+
|
| 16 |
+
The sidecar is `{"<root USD, package-relative>": {"profile_id", "profile_version", ...}}` as `simready-manage`
|
| 17 |
+
writes it; a top-level `profile` / `profile_version` (the shape simready-central's docs describe) is read too. A read
|
| 18 |
+
that still fails after PROFILES_TRIES attempts fails the stage; nothing is written then. The record goes to
|
| 19 |
+
.build/profiles.json.
|
| 20 |
+
|
| 21 |
+
python -m pipeline.run_pipeline profiles --env-file ... [--dry-run]
|
| 22 |
+
"""
|
| 23 |
+
|
| 24 |
+
from __future__ import annotations
|
| 25 |
+
|
| 26 |
+
import json
|
| 27 |
+
import os
|
| 28 |
+
import time
|
| 29 |
+
from collections import Counter
|
| 30 |
+
from dataclasses import dataclass
|
| 31 |
+
from typing import Dict, Iterable, List, Optional, Tuple
|
| 32 |
+
from urllib.parse import quote
|
| 33 |
+
|
| 34 |
+
from . import hf_addressing
|
| 35 |
+
from .categories import _scroll, _url_of, load_vendors, prop_values, with_property
|
| 36 |
+
from .config import BUILD_DIR, Config
|
| 37 |
+
|
| 38 |
+
PROPERTY = "simready_metadata_validation__profile"
|
| 39 |
+
VERSION_PROPERTY = "simready_metadata_validation__profile_version"
|
| 40 |
+
REPORT = BUILD_DIR / "profiles.json"
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
@dataclass(frozen=True)
|
| 44 |
+
class PackageRef:
|
| 45 |
+
host: str
|
| 46 |
+
repo_id: str # "<owner>/<name>" of a dataset repo
|
| 47 |
+
revision: str
|
| 48 |
+
package: str # the package folder at the repo root
|
| 49 |
+
path: str # the asset's file, relative to the package folder
|
| 50 |
+
|
| 51 |
+
@property
|
| 52 |
+
def owner(self) -> str:
|
| 53 |
+
return self.repo_id.split("/")[0]
|
| 54 |
+
|
| 55 |
+
def sidecar_url(self) -> str:
|
| 56 |
+
rel = f"{self.package}/.metadata/{self.package}_validation.json"
|
| 57 |
+
return f"https://{self.host}/datasets/{self.repo_id}/resolve/{self.revision}/{quote(rel)}"
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
# ββ pure rules βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def package_ref(url: str) -> Optional[PackageRef]:
|
| 64 |
+
"""The dataset package an indexed asset belongs to, from its hf:// address or its public
|
| 65 |
+
https://<host>/datasets/<owner>/<name>/blob|resolve/<rev>/<path> URL. None for anything else."""
|
| 66 |
+
if url.startswith("hf://"):
|
| 67 |
+
try:
|
| 68 |
+
addr = hf_addressing.parse(url)
|
| 69 |
+
except hf_addressing.AddressError:
|
| 70 |
+
return None
|
| 71 |
+
if addr.repo_type != "dataset" or "/" not in addr.path:
|
| 72 |
+
return None
|
| 73 |
+
package, path = addr.path.split("/", 1)
|
| 74 |
+
return PackageRef(addr.host, addr.repo_id, addr.revision or "main", package, path)
|
| 75 |
+
parts = url.split("/")
|
| 76 |
+
# https: '' <host> datasets <owner> <name> blob|resolve <rev> <package> <path...>
|
| 77 |
+
if len(parts) > 9 and parts[0] in ("https:", "http:") and parts[3] == "datasets" and parts[6] in ("blob", "resolve"):
|
| 78 |
+
return PackageRef(parts[2], f"{parts[4]}/{parts[5]}", parts[7], parts[8], "/".join(parts[9:]))
|
| 79 |
+
return None
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def needs_sidecar(ref: Optional[PackageRef], props: Iterable[Dict], vendors: Dict) -> bool:
|
| 83 |
+
"""Read the sidecar for a partner package, or for any asset whose USD gave no profile."""
|
| 84 |
+
if ref is None:
|
| 85 |
+
return False
|
| 86 |
+
return ref.owner.lower() not in vendors["own"] or not prop_values(props, PROPERTY)
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def _profile_of(entry: Dict) -> Optional[Tuple[str, str]]:
|
| 90 |
+
profile = str(entry.get("profile_id") or entry.get("profile") or "").strip()
|
| 91 |
+
if not profile:
|
| 92 |
+
return None
|
| 93 |
+
return profile, str(entry.get("profile_version") or "").strip()
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def profile_from_sidecar(doc, path: str) -> Tuple[Optional[Tuple[str, str]], str]:
|
| 97 |
+
"""((profile, version), "ok") from a validation sidecar for the asset at package-relative `path`, or
|
| 98 |
+
(None, why): "malformed", "no-profile" or "ambiguous" (several USDs with different profiles, none of them `path`)."""
|
| 99 |
+
if not isinstance(doc, dict):
|
| 100 |
+
return None, "malformed"
|
| 101 |
+
top = _profile_of(doc)
|
| 102 |
+
if top:
|
| 103 |
+
return top, "ok"
|
| 104 |
+
entries = {k: _profile_of(v) for k, v in doc.items() if isinstance(v, dict)}
|
| 105 |
+
entries = {k: v for k, v in entries.items() if v}
|
| 106 |
+
if path in entries:
|
| 107 |
+
return entries[path], "ok"
|
| 108 |
+
found = set(entries.values())
|
| 109 |
+
if len(found) == 1:
|
| 110 |
+
return found.pop(), "ok"
|
| 111 |
+
return None, "ambiguous" if found else "no-profile"
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def with_profile(props: List[Dict], profile: str, version: str) -> List[Dict]:
|
| 115 |
+
"""`props` carrying exactly this profile (and version, when the sidecar names one). A version the sidecar leaves
|
| 116 |
+
out is kept unless the profile itself changed. The same list back when nothing changes."""
|
| 117 |
+
current = prop_values(props, PROPERTY)
|
| 118 |
+
out = props
|
| 119 |
+
if current != [profile]:
|
| 120 |
+
out = with_property(out, PROPERTY, [profile])
|
| 121 |
+
if not version:
|
| 122 |
+
out = with_property(out, VERSION_PROPERTY, [])
|
| 123 |
+
if version and prop_values(out, VERSION_PROPERTY) != [version]:
|
| 124 |
+
out = with_property(out, VERSION_PROPERTY, [version])
|
| 125 |
+
return out
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
# ββ reading the sidecars βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
class SidecarError(RuntimeError):
|
| 132 |
+
pass
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def fetch_sidecar(http, url: str, tries: int, backoff_s: float) -> Tuple[str, object]:
|
| 136 |
+
"""("ok", parsed sidecar), ("missing", None) when the package has none (404), or ("malformed", None) when it
|
| 137 |
+
is not JSON. Retries 429/5xx and transport errors; anything else, or the last failure, raises SidecarError."""
|
| 138 |
+
last = ""
|
| 139 |
+
for attempt in range(tries):
|
| 140 |
+
if attempt:
|
| 141 |
+
time.sleep(backoff_s * (2 ** (attempt - 1)))
|
| 142 |
+
try:
|
| 143 |
+
resp = http.get(url)
|
| 144 |
+
except Exception as e: # noqa: BLE001 β httpx transport errors; retried, then reported
|
| 145 |
+
last = f"{type(e).__name__}: {e}"
|
| 146 |
+
continue
|
| 147 |
+
if resp.status_code == 404:
|
| 148 |
+
return "missing", None
|
| 149 |
+
if resp.status_code == 200:
|
| 150 |
+
try:
|
| 151 |
+
return "ok", resp.json()
|
| 152 |
+
except ValueError:
|
| 153 |
+
return "malformed", None
|
| 154 |
+
last = f"HTTP {resp.status_code}"
|
| 155 |
+
if resp.status_code != 429 and resp.status_code < 500:
|
| 156 |
+
break
|
| 157 |
+
raise SidecarError(f"{url}: {last}")
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def _headers() -> Dict[str, str]:
|
| 161 |
+
# Public datasets read anonymously; $HF_TOKEN (environment only, as everywhere in this pipeline) when set.
|
| 162 |
+
token = os.environ.get("HF_TOKEN")
|
| 163 |
+
return {"Authorization": f"Bearer {token}"} if token else {}
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
# ββ the stage ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def run(cfg: Config, dry_run: bool = False) -> int:
|
| 170 |
+
import httpx
|
| 171 |
+
|
| 172 |
+
vendors = load_vendors()
|
| 173 |
+
base, index = cfg.opensearch_url, cfg.main_index
|
| 174 |
+
tries = max(1, int(os.environ.get("PROFILES_TRIES", "3")))
|
| 175 |
+
interval = float(os.environ.get("PROFILES_READ_INTERVAL_S", "0.25"))
|
| 176 |
+
outcomes: Counter = Counter()
|
| 177 |
+
values: Counter = Counter()
|
| 178 |
+
sources: Counter = Counter()
|
| 179 |
+
disagreements: List[Dict] = []
|
| 180 |
+
without: List[str] = []
|
| 181 |
+
errors: List[str] = []
|
| 182 |
+
updates: List[Tuple[str, List[Dict]]] = []
|
| 183 |
+
expected: Dict[str, Tuple[List[str], List[str]]] = {}
|
| 184 |
+
read = 0
|
| 185 |
+
with httpx.Client(timeout=60.0, follow_redirects=True) as index_http, \
|
| 186 |
+
httpx.Client(timeout=60.0, follow_redirects=True, headers=_headers()) as hf_http:
|
| 187 |
+
for hit in _scroll(index_http, base, index):
|
| 188 |
+
src = hit.get("_source") or {}
|
| 189 |
+
props = src.get("usd_properties")
|
| 190 |
+
if props is not None and not isinstance(props, list):
|
| 191 |
+
print(f"ERROR: {hit['_id']}: usd_properties is a {type(props).__name__}, not a list β not touching the index")
|
| 192 |
+
return 1
|
| 193 |
+
props = props or []
|
| 194 |
+
url = _url_of(src)
|
| 195 |
+
ref = package_ref(url)
|
| 196 |
+
usd = prop_values(props, PROPERTY)
|
| 197 |
+
new_props = props
|
| 198 |
+
if needs_sidecar(ref, props, vendors):
|
| 199 |
+
if read:
|
| 200 |
+
time.sleep(interval)
|
| 201 |
+
read += 1
|
| 202 |
+
try:
|
| 203 |
+
state, doc = fetch_sidecar(hf_http, ref.sidecar_url(), tries, backoff_s=2.0)
|
| 204 |
+
except SidecarError as e:
|
| 205 |
+
errors.append(str(e))
|
| 206 |
+
continue
|
| 207 |
+
if state == "missing":
|
| 208 |
+
outcomes["no-sidecar"] += 1
|
| 209 |
+
else:
|
| 210 |
+
found, why = profile_from_sidecar(doc, ref.path)
|
| 211 |
+
outcomes[why] += 1
|
| 212 |
+
if found:
|
| 213 |
+
profile, version = found
|
| 214 |
+
if usd and usd != [profile]:
|
| 215 |
+
disagreements.append({"url": url, "usd": usd, "sidecar": profile})
|
| 216 |
+
new_props = with_profile(props, profile, version)
|
| 217 |
+
else:
|
| 218 |
+
outcomes["usd-only"] += 1
|
| 219 |
+
final = prop_values(new_props, PROPERTY)
|
| 220 |
+
values.update(final)
|
| 221 |
+
sources["sidecar" if new_props is not props else ("usd" if final else "none")] += 1
|
| 222 |
+
if not final:
|
| 223 |
+
without.append(url)
|
| 224 |
+
if new_props is not props:
|
| 225 |
+
updates.append((hit["_id"], new_props))
|
| 226 |
+
expected[hit["_id"]] = (final, prop_values(new_props, VERSION_PROPERTY))
|
| 227 |
+
|
| 228 |
+
print(f"{sum(sources.values())} assets in {index}; {read} sidecar(s) read; outcomes: {dict(outcomes)}; "
|
| 229 |
+
f"{len(updates)} doc(s) to update")
|
| 230 |
+
print(f"{PROPERTY}: {json.dumps(dict(values), sort_keys=True)}")
|
| 231 |
+
for d in disagreements:
|
| 232 |
+
print(f"sidecar overrides the USD: {d['url']}: {d['usd']} -> {d['sidecar']}")
|
| 233 |
+
if without:
|
| 234 |
+
print(f"left without {PROPERTY} ({len(without)}): {without[:20]}")
|
| 235 |
+
REPORT.parent.mkdir(parents=True, exist_ok=True)
|
| 236 |
+
REPORT.write_text(json.dumps({
|
| 237 |
+
"property": PROPERTY, "index": index, "assets": sum(sources.values()), "sidecars_read": read,
|
| 238 |
+
"outcomes": dict(outcomes), "values": dict(sorted(values.items())), "disagreements": disagreements,
|
| 239 |
+
"without": without, "errors": errors, "updated": len(updates), "dry_run": dry_run,
|
| 240 |
+
}, indent=1) + "\n")
|
| 241 |
+
if errors:
|
| 242 |
+
print("ERROR: sidecar read failed:\nERROR: " + "\nERROR: ".join(errors[:20]))
|
| 243 |
+
return 1
|
| 244 |
+
if dry_run:
|
| 245 |
+
print("dry run: nothing written")
|
| 246 |
+
return 0
|
| 247 |
+
for i in range(0, len(updates), 200):
|
| 248 |
+
lines = []
|
| 249 |
+
for doc_id, props in updates[i:i + 200]:
|
| 250 |
+
lines.append(json.dumps({"update": {"_index": index, "_id": doc_id}}))
|
| 251 |
+
lines.append(json.dumps({"doc": {"usd_properties": props}}))
|
| 252 |
+
resp = index_http.post(f"{base}/_bulk", content="\n".join(lines) + "\n",
|
| 253 |
+
headers={"content-type": "application/x-ndjson"})
|
| 254 |
+
resp.raise_for_status()
|
| 255 |
+
if resp.json().get("errors"):
|
| 256 |
+
bad = [it for it in resp.json()["items"] if it.get("update", {}).get("error")][:3]
|
| 257 |
+
print(f"ERROR: bulk update failed: {json.dumps(bad)[:800]}")
|
| 258 |
+
return 1
|
| 259 |
+
index_http.post(f"{base}/{index}/_refresh").raise_for_status()
|
| 260 |
+
# Read back: the whole index holds the counted profiles, and every updated doc exactly its own.
|
| 261 |
+
written: Counter = Counter()
|
| 262 |
+
wrong: List[str] = []
|
| 263 |
+
for hit in _scroll(index_http, base, index):
|
| 264 |
+
props = (hit.get("_source") or {}).get("usd_properties") or []
|
| 265 |
+
written.update(prop_values(props, PROPERTY))
|
| 266 |
+
want = expected.get(hit["_id"])
|
| 267 |
+
if want and want != (prop_values(props, PROPERTY), prop_values(props, VERSION_PROPERTY)):
|
| 268 |
+
wrong.append(hit["_id"])
|
| 269 |
+
if written != values or wrong:
|
| 270 |
+
print(f"ERROR: read-back differs: wrote {dict(values)} but the index holds {dict(written)}; docs: {wrong[:10]}")
|
| 271 |
+
return 1
|
| 272 |
+
print(f"wrote {PROPERTY} on {len(updates)} doc(s); the index holds {json.dumps(dict(written), sort_keys=True)}")
|
| 273 |
+
return 0
|
| 274 |
+
|
| 275 |
+
|
| 276 |
+
def main(argv: Optional[List[str]] = None) -> int:
|
| 277 |
+
import argparse
|
| 278 |
+
from pathlib import Path
|
| 279 |
+
|
| 280 |
+
ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
|
| 281 |
+
ap.add_argument("--env-file", type=Path, default=None)
|
| 282 |
+
ap.add_argument("--dry-run", action="store_true")
|
| 283 |
+
a = ap.parse_args(argv)
|
| 284 |
+
return run(Config.load(a.env_file), dry_run=a.dry_run)
|
| 285 |
+
|
| 286 |
+
|
| 287 |
+
if __name__ == "__main__":
|
| 288 |
+
import sys
|
| 289 |
+
|
| 290 |
+
sys.exit(main())
|
pipeline/restore.py
CHANGED
|
@@ -25,6 +25,7 @@ from __future__ import annotations
|
|
| 25 |
|
| 26 |
import gzip
|
| 27 |
import json
|
|
|
|
| 28 |
import shutil
|
| 29 |
import subprocess
|
| 30 |
from pathlib import Path
|
|
@@ -77,6 +78,21 @@ OPENSEARCH_TIMEOUT = 120.0
|
|
| 77 |
BULK_TIMEOUT = 300.0
|
| 78 |
|
| 79 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
def download(cfg: Config, prefix: str, dest: Path = RESTORE_DIR) -> None:
|
| 81 |
# dest is overridable so two independent restores (e.g. the HF Space's
|
| 82 |
# OpenSearch and Neo4j restores, run by separate supervisord programs β
|
|
@@ -159,6 +175,7 @@ def restore_opensearch(cfg: Config, manifest: Dict) -> None:
|
|
| 159 |
os_dir / f"{index}.mapping.json.gz",
|
| 160 |
number_of_shards=cfg.opensearch_number_of_shards,
|
| 161 |
)
|
|
|
|
| 162 |
response = httpx.put(f"{cfg.opensearch_url}/{index}", json=body, timeout=OPENSEARCH_TIMEOUT)
|
| 163 |
if response.status_code >= 400:
|
| 164 |
# raise_for_status()'s own message is just "403 Forbidden" β
|
|
@@ -236,6 +253,8 @@ def run(cfg: Config, fresh: bool = False) -> int:
|
|
| 236 |
manifest = json.loads((RESTORE_DIR / "manifest.json").read_text())
|
| 237 |
|
| 238 |
print("\nRestoring OpenSearch ...")
|
|
|
|
|
|
|
| 239 |
restore_opensearch(cfg, manifest)
|
| 240 |
|
| 241 |
print("\nRestoring Neo4j ...")
|
|
|
|
| 25 |
|
| 26 |
import gzip
|
| 27 |
import json
|
| 28 |
+
import os
|
| 29 |
import shutil
|
| 30 |
import subprocess
|
| 31 |
from pathlib import Path
|
|
|
|
| 78 |
BULK_TIMEOUT = 300.0
|
| 79 |
|
| 80 |
|
| 81 |
+
def keep_derived_source_off(body: dict, mappings_json: str = "") -> None:
|
| 82 |
+
"""RESTORE_KNN_DERIVED_SOURCE=off (the restore stage sets it; the Space's seed does not): create a k-NN index with
|
| 83 |
+
`index.knn.derived_source.enabled: false`, as the workers' own indexes are. build_create_body strips that
|
| 84 |
+
setting, so a restored index falls back to derived source ON, and OpenSearch then rebuilds the nested
|
| 85 |
+
`siglip2-embedding` vectors on read with one vector per segment: the next export writes that one vector onto
|
| 86 |
+
every document of the segment (run 20261012-9d9f42a: 1,399 of 1,404). The Space only searches its index (the
|
| 87 |
+
k-NN graph is built from the loaded vectors), so it keeps the default. Set it to `on` to restore the old behaviour."""
|
| 88 |
+
if os.environ.get("RESTORE_KNN_DERIVED_SOURCE", "").strip().lower() not in ("off", "false", "0"):
|
| 89 |
+
return
|
| 90 |
+
index_settings = body.setdefault("settings", {}).setdefault("index", {})
|
| 91 |
+
if str(index_settings.get("knn", "")).lower() == "true" or '"knn_vector"' in mappings_json:
|
| 92 |
+
index_settings["knn.derived_source.enabled"] = "false"
|
| 93 |
+
print(" k-NN derived source off for this index (RESTORE_KNN_DERIVED_SOURCE=off)")
|
| 94 |
+
|
| 95 |
+
|
| 96 |
def download(cfg: Config, prefix: str, dest: Path = RESTORE_DIR) -> None:
|
| 97 |
# dest is overridable so two independent restores (e.g. the HF Space's
|
| 98 |
# OpenSearch and Neo4j restores, run by separate supervisord programs β
|
|
|
|
| 175 |
os_dir / f"{index}.mapping.json.gz",
|
| 176 |
number_of_shards=cfg.opensearch_number_of_shards,
|
| 177 |
)
|
| 178 |
+
keep_derived_source_off(body, json.dumps(body.get("mappings", {})))
|
| 179 |
response = httpx.put(f"{cfg.opensearch_url}/{index}", json=body, timeout=OPENSEARCH_TIMEOUT)
|
| 180 |
if response.status_code >= 400:
|
| 181 |
# raise_for_status()'s own message is just "403 Forbidden" β
|
|
|
|
| 253 |
manifest = json.loads((RESTORE_DIR / "manifest.json").read_text())
|
| 254 |
|
| 255 |
print("\nRestoring OpenSearch ...")
|
| 256 |
+
# This restore feeds an index that `export` dumps again: keep k-NN derived source off (keep_derived_source_off).
|
| 257 |
+
os.environ.setdefault("RESTORE_KNN_DERIVED_SOURCE", "off")
|
| 258 |
restore_opensearch(cfg, manifest)
|
| 259 |
|
| 260 |
print("\nRestoring Neo4j ...")
|
pipeline/run_pipeline.py
CHANGED
|
@@ -6,7 +6,8 @@ Stages run in order and each is independently re-runnable:
|
|
| 6 |
|
| 7 |
enumerate corpus.json -> exact asset list (N) + manifest
|
| 8 |
index bring the stack up, restore the baseline, clear, submit the
|
| 9 |
-
delta, wait
|
|
|
|
| 10 |
export elasticdump the indexes; neo4j-admin dump the graph
|
| 11 |
upload publish the artifact set to S3
|
| 12 |
bake build images with the data pre-loaded
|
|
@@ -20,7 +21,10 @@ Stages run in order and each is independently re-runnable:
|
|
| 20 |
all every stage in order. `hierarchy` runs right after `verify`
|
| 21 |
with --new-only (only new Q-codes are placed; nothing placed
|
| 22 |
moves), on the verified stack; a failure there only warns β
|
| 23 |
-
the index still ships, with the previous run's tree
|
|
|
|
|
|
|
|
|
|
| 24 |
|
| 25 |
deploy-space deploy the hf_space image to a Hugging Face Space (not
|
| 26 |
part of `all` β run manually after `push`; see
|
|
@@ -76,9 +80,14 @@ def stage_up(cfg: Config, args) -> int:
|
|
| 76 |
if not (BUILD_DIR / "settings.yaml").exists() or not (BUILD_DIR / "plugin.config.yaml").exists():
|
| 77 |
print("`.build/settings.yaml`/`.build/plugin.config.yaml` are missing β run `enumerate` first.")
|
| 78 |
return 1
|
| 79 |
-
if cfg.
|
| 80 |
print(f"siglip2 backend: nvcf ({cfg.get('SIGLIP2_NVCF_ENDPOINT', 'grpc.nvcf.nvidia.com:443')}) "
|
| 81 |
f"β no local GPU needed for embeddings.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
if cfg.vlm_metadata_enabled:
|
| 83 |
cfg.require_vlm_metadata_credentials()
|
| 84 |
model = os.environ.get("USDSEARCH_VISION_METADATA_MODEL", "").strip() or "azure/openai/gpt-5.6-sol (default)"
|
|
@@ -133,20 +142,66 @@ def stage_wait(cfg: Config, args) -> int:
|
|
| 133 |
return run(cfg)
|
| 134 |
|
| 135 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
def stage_categories(cfg: Config, args) -> int:
|
| 137 |
from .categories import run
|
| 138 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
|
| 140 |
|
| 141 |
def stage_index(cfg: Config, args) -> int:
|
| 142 |
-
"""Bring the stack up, restore the baseline, clear, submit the delta, wait, write central:category
|
|
|
|
| 143 |
steps = (
|
| 144 |
("up", stage_up),
|
| 145 |
("restore", stage_restore),
|
| 146 |
("clear", stage_clear),
|
| 147 |
("submit", stage_submit),
|
| 148 |
("wait", stage_wait),
|
|
|
|
| 149 |
("categories", stage_categories),
|
|
|
|
| 150 |
)
|
| 151 |
for step, fn in steps:
|
| 152 |
print(f"\n{'=' * 70}\n== index: {step}\n{'=' * 70}")
|
|
@@ -158,6 +213,14 @@ def stage_index(cfg: Config, args) -> int:
|
|
| 158 |
|
| 159 |
|
| 160 |
def stage_export(cfg: Config, args) -> int:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 161 |
from .export import run
|
| 162 |
return run(cfg)
|
| 163 |
|
|
@@ -247,6 +310,10 @@ def stage_all(cfg: Config, args) -> int:
|
|
| 247 |
args.keep_up = keep_up or hierarchy_next # the hierarchy stage reads the verified stack
|
| 248 |
if name == "hierarchy":
|
| 249 |
args.new_only = True
|
|
|
|
|
|
|
|
|
|
|
|
|
| 250 |
try:
|
| 251 |
code = fn(cfg, args)
|
| 252 |
except (SystemExit, Exception) as e: # noqa: BLE001 β only `hierarchy` may fail softly
|
|
@@ -305,7 +372,9 @@ STAGES = {
|
|
| 305 |
"submit": stage_submit,
|
| 306 |
"wait": stage_wait,
|
| 307 |
"status": stage_wait,
|
|
|
|
| 308 |
"categories": stage_categories,
|
|
|
|
| 309 |
"index": stage_index,
|
| 310 |
"export": stage_export,
|
| 311 |
"upload": stage_upload,
|
|
|
|
| 6 |
|
| 7 |
enumerate corpus.json -> exact asset list (N) + manifest
|
| 8 |
index bring the stack up, restore the baseline, clear, submit the
|
| 9 |
+
delta, wait, then the image-overrides, categories and
|
| 10 |
+
profiles steps
|
| 11 |
export elasticdump the indexes; neo4j-admin dump the graph
|
| 12 |
upload publish the artifact set to S3
|
| 13 |
bake build images with the data pre-loaded
|
|
|
|
| 21 |
all every stage in order. `hierarchy` runs right after `verify`
|
| 22 |
with --new-only (only new Q-codes are placed; nothing placed
|
| 23 |
moves), on the verified stack; a failure there only warns β
|
| 24 |
+
the index still ships, with the previous run's tree β but the
|
| 25 |
+
stage is required before deploying: deploy-space refuses a run
|
| 26 |
+
with no published hierarchy. After `--stop verify`, run it with
|
| 27 |
+
--run-id <run>.
|
| 28 |
|
| 29 |
deploy-space deploy the hf_space image to a Hugging Face Space (not
|
| 30 |
part of `all` β run manually after `push`; see
|
|
|
|
| 80 |
if not (BUILD_DIR / "settings.yaml").exists() or not (BUILD_DIR / "plugin.config.yaml").exists():
|
| 81 |
print("`.build/settings.yaml`/`.build/plugin.config.yaml` are missing β run `enumerate` first.")
|
| 82 |
return 1
|
| 83 |
+
if cfg.indexing_siglip2_nvcf:
|
| 84 |
print(f"siglip2 backend: nvcf ({cfg.get('SIGLIP2_NVCF_ENDPOINT', 'grpc.nvcf.nvidia.com:443')}) "
|
| 85 |
f"β no local GPU needed for embeddings.")
|
| 86 |
+
else:
|
| 87 |
+
print(f"siglip2 backend: local ({cfg.get('SIGLIP2_IMAGE', 'usdsearch-siglip2-triton')}, needs a GPU)")
|
| 88 |
+
if cfg.storage_api_backend and not cfg.hf_token():
|
| 89 |
+
print("WARNING: no Hugging Face token ($HF_TOKEN or `hf auth login`) β hf-storage-api runs "
|
| 90 |
+
"anonymously and HF rate-limits it (429s) on anything but a small corpus.")
|
| 91 |
if cfg.vlm_metadata_enabled:
|
| 92 |
cfg.require_vlm_metadata_credentials()
|
| 93 |
model = os.environ.get("USDSEARCH_VISION_METADATA_MODEL", "").strip() or "azure/openai/gpt-5.6-sol (default)"
|
|
|
|
| 142 |
return run(cfg)
|
| 143 |
|
| 144 |
|
| 145 |
+
# The steps `index` runs after `wait`. Each writes into the index, so `export` refuses to run until all three have
|
| 146 |
+
# succeeded after the latest `wait`: resuming with `wait` and then `all --start export` used to skip them silently
|
| 147 |
+
# (2026-10-16), shipping an index without image overrides, the OEM category tree or partner profiles.
|
| 148 |
+
ENRICHMENT_STEPS = ("image-overrides", "categories", "profiles")
|
| 149 |
+
STAGE_MARKERS = BUILD_DIR / "stages"
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def _mark_done(step: str, args) -> None:
|
| 153 |
+
if not getattr(args, "dry_run", False):
|
| 154 |
+
STAGE_MARKERS.mkdir(parents=True, exist_ok=True)
|
| 155 |
+
(STAGE_MARKERS / f"{step}.done").touch()
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def enrichment_missing() -> List[str]:
|
| 159 |
+
"""The ENRICHMENT_STEPS that haven't succeeded since the latest `wait` (its .build/index-report.json)."""
|
| 160 |
+
report = BUILD_DIR / "index-report.json"
|
| 161 |
+
waited = report.stat().st_mtime if report.exists() else 0.0
|
| 162 |
+
return [
|
| 163 |
+
step for step in ENRICHMENT_STEPS
|
| 164 |
+
if not (STAGE_MARKERS / f"{step}.done").exists()
|
| 165 |
+
or (STAGE_MARKERS / f"{step}.done").stat().st_mtime < waited
|
| 166 |
+
]
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def stage_image_overrides(cfg: Config, args) -> int:
|
| 170 |
+
from .image_overrides import run
|
| 171 |
+
code = run(cfg, dry_run=args.dry_run)
|
| 172 |
+
if code == 0:
|
| 173 |
+
_mark_done("image-overrides", args)
|
| 174 |
+
return code
|
| 175 |
+
|
| 176 |
+
|
| 177 |
def stage_categories(cfg: Config, args) -> int:
|
| 178 |
from .categories import run
|
| 179 |
+
code = run(cfg, dry_run=args.dry_run)
|
| 180 |
+
if code == 0:
|
| 181 |
+
_mark_done("categories", args)
|
| 182 |
+
return code
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
def stage_profiles(cfg: Config, args) -> int:
|
| 186 |
+
from .profiles import run
|
| 187 |
+
code = run(cfg, dry_run=args.dry_run)
|
| 188 |
+
if code == 0:
|
| 189 |
+
_mark_done("profiles", args)
|
| 190 |
+
return code
|
| 191 |
|
| 192 |
|
| 193 |
def stage_index(cfg: Config, args) -> int:
|
| 194 |
+
"""Bring the stack up, restore the baseline, clear, submit the delta, wait, write central:category and the
|
| 195 |
+
validation profiles the packages' sidecars name."""
|
| 196 |
steps = (
|
| 197 |
("up", stage_up),
|
| 198 |
("restore", stage_restore),
|
| 199 |
("clear", stage_clear),
|
| 200 |
("submit", stage_submit),
|
| 201 |
("wait", stage_wait),
|
| 202 |
+
("image-overrides", stage_image_overrides),
|
| 203 |
("categories", stage_categories),
|
| 204 |
+
("profiles", stage_profiles),
|
| 205 |
)
|
| 206 |
for step, fn in steps:
|
| 207 |
print(f"\n{'=' * 70}\n== index: {step}\n{'=' * 70}")
|
|
|
|
| 213 |
|
| 214 |
|
| 215 |
def stage_export(cfg: Config, args) -> int:
|
| 216 |
+
missing = enrichment_missing()
|
| 217 |
+
if missing:
|
| 218 |
+
print(
|
| 219 |
+
f"refusing to export: {', '.join(missing)} not run since the last `wait`. They write into the index "
|
| 220 |
+
f"(image overrides, central:category, partner profiles). Run them in order, then export:\n"
|
| 221 |
+
+ "".join(f" python -m pipeline.run_pipeline {step} --env-file ...\n" for step in missing)
|
| 222 |
+
)
|
| 223 |
+
return 1
|
| 224 |
from .export import run
|
| 225 |
return run(cfg)
|
| 226 |
|
|
|
|
| 310 |
args.keep_up = keep_up or hierarchy_next # the hierarchy stage reads the verified stack
|
| 311 |
if name == "hierarchy":
|
| 312 |
args.new_only = True
|
| 313 |
+
# This run, not latest.json's: under --no-latest that is still the previous run, whose tree the Spaces
|
| 314 |
+
# serve (2026-10-19: 20261019-9f72902's hierarchy started publishing into 20261015-09c47a6/hierarchy/).
|
| 315 |
+
from .enumerate_assets import load_manifest
|
| 316 |
+
args.run_id = getattr(args, "run_id", None) or load_manifest()["run_id"]
|
| 317 |
try:
|
| 318 |
code = fn(cfg, args)
|
| 319 |
except (SystemExit, Exception) as e: # noqa: BLE001 β only `hierarchy` may fail softly
|
|
|
|
| 372 |
"submit": stage_submit,
|
| 373 |
"wait": stage_wait,
|
| 374 |
"status": stage_wait,
|
| 375 |
+
"image-overrides": stage_image_overrides,
|
| 376 |
"categories": stage_categories,
|
| 377 |
+
"profiles": stage_profiles,
|
| 378 |
"index": stage_index,
|
| 379 |
"export": stage_export,
|
| 380 |
"upload": stage_upload,
|
pipeline/status.py
CHANGED
|
@@ -119,6 +119,12 @@ def classify(doc: Dict, plugins: Tuple[str, ...]) -> Dict[str, Tuple[str, str]]:
|
|
| 119 |
async def poll_until_done(cfg: Config, urls: List[str]) -> Tuple[Dict[str, Tally], List[str]]:
|
| 120 |
"""Poll the outstanding set until every (asset, plugin) pair is terminal."""
|
| 121 |
interval = cfg.get_float("STATUS_POLL_INTERVAL", 15.0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
timeout = cfg.get_float("STATUS_TIMEOUT", 21600.0)
|
| 123 |
concurrency = cfg.get_int("STATUS_CONCURRENCY", 16)
|
| 124 |
redis_url = f"redis://localhost:{cfg.get_int('REDIS_HOST_PORT', 6380)}"
|
|
@@ -132,6 +138,7 @@ async def poll_until_done(cfg: Config, urls: List[str]) -> Tuple[Dict[str, Tally
|
|
| 132 |
redis_client = connect(redis_url)
|
| 133 |
|
| 134 |
limits = httpx.Limits(max_connections=concurrency * 2)
|
|
|
|
| 135 |
async with httpx.AsyncClient(limits=limits, timeout=httpx.Timeout(30.0)) as client:
|
| 136 |
while outstanding:
|
| 137 |
if time.monotonic() > deadline:
|
|
@@ -139,6 +146,15 @@ async def poll_until_done(cfg: Config, urls: List[str]) -> Tuple[Dict[str, Tally
|
|
| 139 |
dump_worker_logs(cfg)
|
| 140 |
break
|
| 141 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
async def check(url: str) -> Tuple[str, Optional[Dict]]:
|
| 143 |
async with semaphore:
|
| 144 |
return url, await fetch_status(client, cfg.info_endpoint, url)
|
|
|
|
| 119 |
async def poll_until_done(cfg: Config, urls: List[str]) -> Tuple[Dict[str, Tally], List[str]]:
|
| 120 |
"""Poll the outstanding set until every (asset, plugin) pair is terminal."""
|
| 121 |
interval = cfg.get_float("STATUS_POLL_INTERVAL", 15.0)
|
| 122 |
+
# Each asset-status request makes info-endpoint list the asset through the
|
| 123 |
+
# storage backend. With storage_api that is an HF tree listing, so polling
|
| 124 |
+
# ~2,600 assets every cycle exhausted HF's API quota (429s) and starved the
|
| 125 |
+
# workers. While jobs are still queued, watch only the Redis queues and do
|
| 126 |
+
# a full per-asset poll at most every STATUS_FULL_POLL_INTERVAL seconds.
|
| 127 |
+
full_poll_interval = cfg.get_float("STATUS_FULL_POLL_INTERVAL", 600.0)
|
| 128 |
timeout = cfg.get_float("STATUS_TIMEOUT", 21600.0)
|
| 129 |
concurrency = cfg.get_int("STATUS_CONCURRENCY", 16)
|
| 130 |
redis_url = f"redis://localhost:{cfg.get_int('REDIS_HOST_PORT', 6380)}"
|
|
|
|
| 138 |
redis_client = connect(redis_url)
|
| 139 |
|
| 140 |
limits = httpx.Limits(max_connections=concurrency * 2)
|
| 141 |
+
last_full_poll: Optional[float] = None
|
| 142 |
async with httpx.AsyncClient(limits=limits, timeout=httpx.Timeout(30.0)) as client:
|
| 143 |
while outstanding:
|
| 144 |
if time.monotonic() > deadline:
|
|
|
|
| 146 |
dump_worker_logs(cfg)
|
| 147 |
break
|
| 148 |
|
| 149 |
+
if last_full_poll is not None and time.monotonic() - last_full_poll < full_poll_interval:
|
| 150 |
+
depths = await queue_depths(redis_client, plugins)
|
| 151 |
+
queued = sum(v for k, v in depths.items() if k != "results")
|
| 152 |
+
if queued or depths["results"]:
|
| 153 |
+
print(f" queued={queued} results={depths['results']}", flush=True)
|
| 154 |
+
await asyncio.sleep(interval)
|
| 155 |
+
continue
|
| 156 |
+
last_full_poll = time.monotonic()
|
| 157 |
+
|
| 158 |
async def check(url: str) -> Tuple[str, Optional[Dict]]:
|
| 159 |
async with semaphore:
|
| 160 |
return url, await fetch_status(client, cfg.info_endpoint, url)
|
services/explorer/dist/build-info.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
{
|
| 2 |
"product": "simready-central-slim-explorer",
|
| 3 |
-
"version": "slim-ui-
|
| 4 |
-
"bundle_id": "main.
|
| 5 |
-
"built_at": "2026-10-
|
| 6 |
"flags": {"public_mode": true, "email_auth": false, "create_ui": false, "hierarchy_editor": false, "create_search": false, "auth_ui": false, "credentials_ui": false, "index_management": false, "inference_backend": "hub", "vlm_asset_refs": false, "validation_image_max_px": 448, "validation_max_retries": 0, "validation_max_concurrent": 32, "validation_tail_concurrent": 8, "validation_eager_count": 50, "validation_batch_stream": true, "library_idle_prefetch": false, "validation_score_scale": "percent", "parse_gate_fields": true, "parse_fast_path": true, "featured_landing": true, "hf_download_commands": true, "dependency_views": true, "model_viewer": false, "library_viewer": true, "library_media": "runtime", "spell_correction": true, "downloads": "runtime", "submit_content": "runtime", "third_party_notices": "runtime"}
|
| 7 |
}
|
|
|
|
| 1 |
{
|
| 2 |
"product": "simready-central-slim-explorer",
|
| 3 |
+
"version": "slim-ui-e4522a3f",
|
| 4 |
+
"bundle_id": "main.01872f5b.js",
|
| 5 |
+
"built_at": "2026-10-18T17:30:52Z",
|
| 6 |
"flags": {"public_mode": true, "email_auth": false, "create_ui": false, "hierarchy_editor": false, "create_search": false, "auth_ui": false, "credentials_ui": false, "index_management": false, "inference_backend": "hub", "vlm_asset_refs": false, "validation_image_max_px": 448, "validation_max_retries": 0, "validation_max_concurrent": 32, "validation_tail_concurrent": 8, "validation_eager_count": 50, "validation_batch_stream": true, "library_idle_prefetch": false, "validation_score_scale": "percent", "parse_gate_fields": true, "parse_fast_path": true, "featured_landing": true, "hf_download_commands": true, "dependency_views": true, "model_viewer": false, "library_viewer": true, "library_media": "runtime", "spell_correction": true, "downloads": "runtime", "submit_content": "runtime", "third_party_notices": "runtime"}
|
| 7 |
}
|
services/explorer/dist/index.html
CHANGED
|
@@ -1 +1 @@
|
|
| 1 |
-
<!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="icon" href="/ds.png"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#000000"/><meta name="description" content="SimReady Central β SimReady asset search"/><meta name="ui-version" content="slim-ui-
|
|
|
|
| 1 |
+
<!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="icon" href="/ds.png"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#000000"/><meta name="description" content="SimReady Central β SimReady asset search"/><meta name="ui-version" content="slim-ui-e4522a3f"><script src="/config.js"></script><title>SimReady Central</title><script defer="defer" src="/static/js/main.01872f5b.js"></script><link href="/static/css/main.ed151f6a.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>
|
services/explorer/dist/third-party-notices.txt
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
Third-party notices β SimReady Central Explorer slim-ui-
|
| 2 |
|
| 3 |
The software in this web application's browser bundle includes the packages below, each under its own
|
| 4 |
license. Each entry gives the package, its version, its license and the license / notice text it ships.
|
|
|
|
| 1 |
+
Third-party notices β SimReady Central Explorer slim-ui-e4522a3f
|
| 2 |
|
| 3 |
The software in this web application's browser bundle includes the packages below, each under its own
|
| 4 |
license. Each entry gives the package, its version, its license and the license / notice text it ships.
|