nspasojevic commited on
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
Files changed (1) hide show
  1. 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 — and
5
- they are kept in the artifact bucket next to the index runs, one archive per corpus:
6
- ``<ARTIFACT_S3_URI>/library-media/<CORPUS_NAME>/media.tar``, an uncompressed tar of ``manifest.json`` +
7
- ``<id>/<file>`` (its members are already compressed). Outside ``<corpus>/``, so nothing that lists a corpus's runs
8
- sees it; corpus-level, not per run, because the media are keyed by the assets' URLs, not by an index build.
9
-
10
- Run once at container start by supervisord (program ``library-media``), this downloads that ONE object with the
11
- same aws CLI and credentials as the index restore (artifact_store), unpacks it and installs the tree as
12
- ``.build/library_media/<INDEX_KEY>/``; nginx serves it at /library_media/ (docker/hf-space/nginx.conf.template), where
13
- the Explorer asks for /library_media/usdsearch-dev/manifest.json — usdsearch-dev being its index key in /config.js.
14
- NOT under ``.build/restore/``: restore.download empties that whole directory at every start.
15
-
16
- Nothing here is needed to search. Any failure — no archive for this corpus, no bucket credentials, a network error,
17
- an unsafe or malformed archive — leaves the previous tree (none at boot): nginx answers 404 and the Explorer shows no
18
- media. LIBRARY_MEDIA=off disables the fetch.
 
 
 
 
 
 
 
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
- ARCHIVE = "media.tar"
35
- PREFIX = "library-media"
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
- def archive_uri(cfg: Config, corpus: str) -> str:
43
- """Where this corpus's media archive lives in the artifact bucket."""
44
- return f"{cfg.require('ARTIFACT_S3_URI').rstrip('/')}/{PREFIX}/{corpus}/{ARCHIVE}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 cli_download(cfg: Config, uri: str, dest: Path, attempts: int = 3) -> bool:
83
- """``aws s3 cp`` ``uri`` to ``dest``; False when there is no such object (this image's artifact_store is the
84
- aws-CLI one, before indexing PR #25's ``download_file``)."""
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 download is None:
111
- download = cli_download
112
  cfg = Config(values=dict(env))
113
- uri = archive_uri(cfg, corpus)
 
 
 
 
 
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 / ARCHIVE
120
- if not download(cfg, uri, archive):
121
- print(f"==> library media: no {uri} — none served")
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")