Spaces:
Running on RTX PRO 6000
Running on RTX PRO 6000
Commit ·
9743fbb
1
Parent(s): 71ed715
Library media from the index run folder (<corpus>/<run>/library-media/media-<stamp>.tar, newest wins, else the latest earlier run's) — indexing PR #24 (ace1e6b), via this image's aws-CLI artifact_store (GPU variant)
Browse files- pipeline/library_media.py +120 -48
pipeline/library_media.py
CHANGED
|
@@ -1,47 +1,133 @@
|
|
| 1 |
"""Library media for the Space's Explorer: the joints viewer's models, moving-parts and drop-test clips.
|
| 2 |
|
| 3 |
SimReady Central builds these per asset — model.glb + articulation.json for each prop with moving joints
|
| 4 |
-
(its scripts/library_media/build.py), the moving-parts clips its lab render pool renders, and the drop tests
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
"""
|
| 20 |
|
| 21 |
from __future__ import annotations
|
| 22 |
|
| 23 |
import json
|
| 24 |
import os
|
|
|
|
| 25 |
import shutil
|
| 26 |
import subprocess
|
| 27 |
import sys
|
| 28 |
import tarfile
|
| 29 |
import time
|
| 30 |
from pathlib import Path
|
|
|
|
| 31 |
|
| 32 |
from .config import BUILD_DIR, Config
|
| 33 |
|
| 34 |
-
|
| 35 |
-
|
| 36 |
# The Explorer's index key for this Space (/config.js in nginx.conf.template): the manifest's URL names it.
|
| 37 |
INDEX_KEY = "usdsearch-dev"
|
| 38 |
MEDIA_ROOT = BUILD_DIR / "library_media"
|
| 39 |
DISABLED = {"off", "none", "false", "0"}
|
| 40 |
|
| 41 |
|
| 42 |
-
|
| 43 |
-
"""
|
| 44 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
|
| 46 |
|
| 47 |
def valid_manifest(path: Path) -> int | None:
|
|
@@ -79,27 +165,9 @@ def install(src: Path, dest: Path) -> None:
|
|
| 79 |
shutil.rmtree(old, ignore_errors=True)
|
| 80 |
|
| 81 |
|
| 82 |
-
def
|
| 83 |
-
"""
|
| 84 |
-
|
| 85 |
-
from .artifact_store import artifact_env, aws_cmd
|
| 86 |
-
dest.parent.mkdir(parents=True, exist_ok=True)
|
| 87 |
-
for attempt in range(1, attempts + 1):
|
| 88 |
-
r = subprocess.run(aws_cmd(cfg) + ["cp", uri, str(dest), "--only-show-errors"],
|
| 89 |
-
capture_output=True, text=True, env=artifact_env(), timeout=1800)
|
| 90 |
-
if r.returncode == 0:
|
| 91 |
-
return True
|
| 92 |
-
err = (r.stderr or r.stdout).strip()
|
| 93 |
-
if "(404)" in err or "Not Found" in err or "does not exist" in err:
|
| 94 |
-
return False
|
| 95 |
-
print(f"==> library media: aws s3 cp failed (attempt {attempt}/{attempts}, exit {r.returncode}): {err[:300]}")
|
| 96 |
-
if attempt < attempts:
|
| 97 |
-
time.sleep(5 * attempt)
|
| 98 |
-
raise RuntimeError(f"aws s3 cp {uri} failed {attempts} times")
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
def fetch(env: dict[str, str], root: Path = MEDIA_ROOT, download=None) -> int:
|
| 102 |
-
"""Fetch this corpus's media into ``root/INDEX_KEY``; returns the number of assets installed (0: none)."""
|
| 103 |
corpus = env.get("CORPUS_NAME", "").strip()
|
| 104 |
if env.get("LIBRARY_MEDIA", "").strip().lower() in DISABLED:
|
| 105 |
print("==> library media: off (LIBRARY_MEDIA)")
|
|
@@ -107,18 +175,22 @@ def fetch(env: dict[str, str], root: Path = MEDIA_ROOT, download=None) -> int:
|
|
| 107 |
if not corpus or "/" in corpus or corpus.startswith("."):
|
| 108 |
print(f"==> library media: no usable CORPUS_NAME ({corpus!r}), skipped")
|
| 109 |
return 0
|
| 110 |
-
if
|
| 111 |
-
|
| 112 |
cfg = Config(values=dict(env))
|
| 113 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
root.mkdir(parents=True, exist_ok=True)
|
| 115 |
tmp = root / f".{INDEX_KEY}.download"
|
| 116 |
shutil.rmtree(tmp, ignore_errors=True)
|
| 117 |
-
t0 = time.time()
|
| 118 |
try:
|
| 119 |
-
archive = tmp /
|
| 120 |
-
if not
|
| 121 |
-
print(f"==> library media:
|
| 122 |
return 0
|
| 123 |
unpack(archive, tmp / "tree")
|
| 124 |
count = valid_manifest(tmp / "tree" / "manifest.json")
|
|
|
|
| 1 |
"""Library media for the Space's Explorer: the joints viewer's models, moving-parts and drop-test clips.
|
| 2 |
|
| 3 |
SimReady Central builds these per asset — model.glb + articulation.json for each prop with moving joints
|
| 4 |
+
(its scripts/library_media/build.py), the moving-parts clips its lab render pool renders, and the drop tests. They
|
| 5 |
+
live with the index run they belong to, next to its hierarchy/ (pipeline/hierarchy/store.py):
|
| 6 |
+
|
| 7 |
+
<ARTIFACT_S3_URI>/<corpus>/<run_id>/library-media/media-<YYYYMMDDTHHMMZ>.tar
|
| 8 |
+
|
| 9 |
+
an uncompressed tar of ``manifest.json`` + ``<id>/<file>`` (its members are already compressed). Each upload is a NEW
|
| 10 |
+
file, named by when it was made, never written over: the newest one in the run is served, so a bad upload is
|
| 11 |
+
reverted by deleting it (the one before it comes back), and pinning the Space to an earlier run (RESTORE_RUN_ID)
|
| 12 |
+
brings back that run's media with its index.
|
| 13 |
+
|
| 14 |
+
Run once at container start by supervisord (program ``library-media``), this finds the run the Space restores (the
|
| 15 |
+
same rule as hf_space_seed.resolve_prefix: RESTORE_RUN_ID, else the corpus's latest.json), takes that run's newest
|
| 16 |
+
archive — else the newest one of the latest EARLIER run that has any (a new index run keeps the media until its own
|
| 17 |
+
are uploaded; a later run's never apply to an earlier one) — downloads it with the same aws CLI and credentials as
|
| 18 |
+
the index restore (artifact_store; CliStore below), unpacks it and installs the tree as ``.build/library_media/<INDEX_KEY>/``; nginx
|
| 19 |
+
serves it at /library_media/ (docker/hf-space/nginx.conf.template), where the Explorer asks for
|
| 20 |
+
/library_media/usdsearch-dev/manifest.json — usdsearch-dev being its index key in /config.js. NOT under
|
| 21 |
+
``.build/restore/``: restore.download empties that whole directory at every start.
|
| 22 |
+
|
| 23 |
+
Nothing here is needed to search. Any failure — no archive, no bucket credentials, a network error, an unsafe or
|
| 24 |
+
malformed archive — leaves the previous tree (none at boot): nginx answers 404 and the Explorer shows no media.
|
| 25 |
+
LIBRARY_MEDIA=off disables the fetch.
|
| 26 |
"""
|
| 27 |
|
| 28 |
from __future__ import annotations
|
| 29 |
|
| 30 |
import json
|
| 31 |
import os
|
| 32 |
+
import re
|
| 33 |
import shutil
|
| 34 |
import subprocess
|
| 35 |
import sys
|
| 36 |
import tarfile
|
| 37 |
import time
|
| 38 |
from pathlib import Path
|
| 39 |
+
from typing import Optional
|
| 40 |
|
| 41 |
from .config import BUILD_DIR, Config
|
| 42 |
|
| 43 |
+
SUBDIR = "library-media"
|
| 44 |
+
ARCHIVE = re.compile(r"^media-\d{8}T\d{4}Z\.tar$") # media-20261005T1010Z.tar: the name sorts by time
|
| 45 |
# The Explorer's index key for this Space (/config.js in nginx.conf.template): the manifest's URL names it.
|
| 46 |
INDEX_KEY = "usdsearch-dev"
|
| 47 |
MEDIA_ROOT = BUILD_DIR / "library_media"
|
| 48 |
DISABLED = {"off", "none", "false", "0"}
|
| 49 |
|
| 50 |
|
| 51 |
+
class CliStore:
|
| 52 |
+
"""What fetch() reads from the bucket, over this image's aws-CLI artifact_store (before indexing PR #25, which
|
| 53 |
+
adds read_text / list_objects / list_subprefixes / download_file in boto3)."""
|
| 54 |
+
|
| 55 |
+
def __init__(self):
|
| 56 |
+
from . import artifact_store
|
| 57 |
+
self.s = artifact_store
|
| 58 |
+
|
| 59 |
+
def bucket_base(self, cfg: Config) -> str:
|
| 60 |
+
return self.s.bucket_base(cfg)
|
| 61 |
+
|
| 62 |
+
def _run(self, cfg: Config, args: list, timeout: int):
|
| 63 |
+
return subprocess.run(self.s.aws_cmd(cfg) + args, capture_output=True, text=True, env=self.s.artifact_env(),
|
| 64 |
+
timeout=timeout)
|
| 65 |
+
|
| 66 |
+
@staticmethod
|
| 67 |
+
def _missing(r) -> bool:
|
| 68 |
+
err = r.stderr or r.stdout or ""
|
| 69 |
+
return "(404)" in err or "Not Found" in err or "NoSuchKey" in err or "does not exist" in err
|
| 70 |
+
|
| 71 |
+
def read_text(self, cfg: Config, uri: str) -> Optional[str]:
|
| 72 |
+
r = self._run(cfg, ["cp", uri, "-"], 120)
|
| 73 |
+
if r.returncode == 0:
|
| 74 |
+
return r.stdout
|
| 75 |
+
if self._missing(r):
|
| 76 |
+
return None
|
| 77 |
+
raise RuntimeError(f"aws s3 cp {uri} - failed (exit {r.returncode}): {(r.stderr or '').strip()[:300]}")
|
| 78 |
+
|
| 79 |
+
def _ls(self, cfg: Config, uri: str) -> list:
|
| 80 |
+
r = self._run(cfg, ["ls", uri.rstrip("/") + "/"], 120)
|
| 81 |
+
if r.returncode == 0:
|
| 82 |
+
return r.stdout.splitlines()
|
| 83 |
+
if r.returncode == 1 and not (r.stderr or "").strip(): # aws s3 ls: nothing under the prefix
|
| 84 |
+
return []
|
| 85 |
+
raise RuntimeError(f"aws s3 ls {uri} failed (exit {r.returncode}): {(r.stderr or '').strip()[:300]}")
|
| 86 |
+
|
| 87 |
+
def list_subprefixes(self, cfg: Config, uri: str) -> list:
|
| 88 |
+
return [ln.split()[-1].rstrip("/") for ln in self._ls(cfg, uri) if ln.strip().startswith("PRE ")]
|
| 89 |
+
|
| 90 |
+
def list_objects(self, cfg: Config, uri: str) -> list:
|
| 91 |
+
rows = [ln.split(None, 3) for ln in self._ls(cfg, uri) if not ln.strip().startswith("PRE ")]
|
| 92 |
+
return [(row[3], int(row[2])) for row in rows if len(row) == 4 and row[2].isdigit()]
|
| 93 |
+
|
| 94 |
+
def download_file(self, cfg: Config, uri: str, dest: Path, attempts: int = 3) -> bool:
|
| 95 |
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
| 96 |
+
for attempt in range(1, attempts + 1):
|
| 97 |
+
r = self._run(cfg, ["cp", uri, str(dest), "--only-show-errors"], 1800)
|
| 98 |
+
if r.returncode == 0:
|
| 99 |
+
return True
|
| 100 |
+
if self._missing(r):
|
| 101 |
+
return False
|
| 102 |
+
print(f"==> library media: aws s3 cp failed (attempt {attempt}/{attempts}, exit {r.returncode}): "
|
| 103 |
+
f"{(r.stderr or r.stdout).strip()[:300]}")
|
| 104 |
+
if attempt < attempts:
|
| 105 |
+
time.sleep(5 * attempt)
|
| 106 |
+
raise RuntimeError(f"aws s3 cp {uri} failed {attempts} times")
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def run_prefix(cfg: Config, store, corpus: str) -> str:
|
| 110 |
+
"""The run the Space restores: RESTORE_RUN_ID, else latest.json (hf_space_seed.resolve_prefix's rule)."""
|
| 111 |
+
base = f"{store.bucket_base(cfg)}/{corpus}"
|
| 112 |
+
pinned = cfg.get("RESTORE_RUN_ID").strip()
|
| 113 |
+
if pinned:
|
| 114 |
+
return f"{base}/{pinned}"
|
| 115 |
+
pointer = store.read_text(cfg, f"{base}/latest.json")
|
| 116 |
+
if pointer is None:
|
| 117 |
+
raise SystemExit(f"no published run at {base}/latest.json")
|
| 118 |
+
return json.loads(pointer)["prefix"].rstrip("/")
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def archive_uri(cfg: Config, store, prefix: str) -> Optional[str]:
|
| 122 |
+
"""The newest archive of the run at ``prefix``, else of the latest earlier run that has any; None: there is none.
|
| 123 |
+
Run ids start with the date (hierarchy/store.py), so they sort by age."""
|
| 124 |
+
base, run = prefix.rsplit("/", 1)
|
| 125 |
+
earlier = [r for r in sorted(store.list_subprefixes(cfg, base), reverse=True) if r < run]
|
| 126 |
+
for r in [run] + earlier:
|
| 127 |
+
names = [rel for rel, _ in store.list_objects(cfg, f"{base}/{r}/{SUBDIR}") if ARCHIVE.match(rel)]
|
| 128 |
+
if names:
|
| 129 |
+
return f"{base}/{r}/{SUBDIR}/{max(names)}"
|
| 130 |
+
return None
|
| 131 |
|
| 132 |
|
| 133 |
def valid_manifest(path: Path) -> int | None:
|
|
|
|
| 165 |
shutil.rmtree(old, ignore_errors=True)
|
| 166 |
|
| 167 |
|
| 168 |
+
def fetch(env: dict[str, str], root: Path = MEDIA_ROOT, store=None) -> int:
|
| 169 |
+
"""Fetch this corpus's media into ``root/INDEX_KEY``; returns the number of assets installed (0: none).
|
| 170 |
+
``store``: artifact_store's read_text / list_subprefixes / list_objects / download_file / bucket_base."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
corpus = env.get("CORPUS_NAME", "").strip()
|
| 172 |
if env.get("LIBRARY_MEDIA", "").strip().lower() in DISABLED:
|
| 173 |
print("==> library media: off (LIBRARY_MEDIA)")
|
|
|
|
| 175 |
if not corpus or "/" in corpus or corpus.startswith("."):
|
| 176 |
print(f"==> library media: no usable CORPUS_NAME ({corpus!r}), skipped")
|
| 177 |
return 0
|
| 178 |
+
if store is None:
|
| 179 |
+
store = CliStore()
|
| 180 |
cfg = Config(values=dict(env))
|
| 181 |
+
t0 = time.time()
|
| 182 |
+
prefix = run_prefix(cfg, store, corpus)
|
| 183 |
+
uri = archive_uri(cfg, store, prefix)
|
| 184 |
+
if uri is None:
|
| 185 |
+
print(f"==> library media: none for {prefix} or an earlier run — none served")
|
| 186 |
+
return 0
|
| 187 |
root.mkdir(parents=True, exist_ok=True)
|
| 188 |
tmp = root / f".{INDEX_KEY}.download"
|
| 189 |
shutil.rmtree(tmp, ignore_errors=True)
|
|
|
|
| 190 |
try:
|
| 191 |
+
archive = tmp / "media.tar"
|
| 192 |
+
if not store.download_file(cfg, uri, archive):
|
| 193 |
+
print(f"==> library media: {uri} is gone — none served")
|
| 194 |
return 0
|
| 195 |
unpack(archive, tmp / "tree")
|
| 196 |
count = valid_manifest(tmp / "tree" / "manifest.json")
|