macayaven commited on
Commit
036c0e9
·
verified ·
1 Parent(s): 3044bfe

Deploy relay source

Browse files
src/small_cuts/hf_relay.py ADDED
@@ -0,0 +1,245 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Hugging Face bucket relay for finished Small Cuts scenes.
2
+
3
+ The Space uses this as a read-only scene source. The private engine or a local
4
+ publisher writes finished scene manifests + media into an HF bucket; the Space
5
+ downloads those files into a temp cache and serves them through Gradio.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import copy
11
+ import json
12
+ import tempfile
13
+ from dataclasses import dataclass
14
+ from datetime import datetime, timezone
15
+ from pathlib import Path
16
+ from typing import Any, Protocol
17
+ from urllib.parse import quote, urlparse
18
+
19
+ import httpx
20
+
21
+ RELAY_BUCKET_ENV = "SMALL_CUTS_RELAY_BUCKET"
22
+ RELAY_PREFIX_ENV = "SMALL_CUTS_RELAY_PREFIX"
23
+ DEFAULT_RELAY_PREFIX = "relay"
24
+ RELAY_MANIFEST = "manifest.json"
25
+ RELAY_CACHE_DIR = Path(tempfile.gettempdir()) / "small-cuts-hf-relay"
26
+ GRADIO_FILE_ROUTE = "/gradio_api/file="
27
+ DEFAULT_SCENE_LIMIT = 60
28
+ MEDIA_KEYS = ("frame_url", "card_url", "audio_url", "clip_url")
29
+ PUBLISH_VISIBILITIES = frozenset({"shared", "public"})
30
+ HTTP_TIMEOUT_S = 20.0
31
+
32
+
33
+ class BucketFileSystem(Protocol):
34
+ def cat(self, path: str) -> bytes: ...
35
+
36
+
37
+ class BucketRelayError(RuntimeError):
38
+ """Raised when the bucket relay cannot read or hydrate its manifest."""
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class RelaySnapshot:
43
+ path: Path
44
+ scene_count: int
45
+ manifest_path: Path
46
+
47
+
48
+ def gradio_file_url(path: str | Path) -> str:
49
+ return f"{GRADIO_FILE_ROUTE}{quote(str(path))}"
50
+
51
+
52
+ def _normalize_prefix(prefix: str) -> str:
53
+ return prefix.strip().strip("/")
54
+
55
+
56
+ def _safe_bucket_slug(bucket_id: str) -> str:
57
+ return bucket_id.replace("/", "__")
58
+
59
+
60
+ class BucketSceneClient:
61
+ """Read finished NarratedScene payloads from a Hugging Face bucket manifest."""
62
+
63
+ base_url = ""
64
+ readonly = True
65
+
66
+ def __init__(
67
+ self,
68
+ bucket_id: str,
69
+ *,
70
+ prefix: str = DEFAULT_RELAY_PREFIX,
71
+ fs: BucketFileSystem | None = None,
72
+ cache_dir: str | Path | None = None,
73
+ register_static_paths: Any | None = None,
74
+ ) -> None:
75
+ self.bucket_id = bucket_id.strip()
76
+ if not self.bucket_id:
77
+ raise ValueError("bucket_id is required")
78
+ self.prefix = _normalize_prefix(prefix)
79
+ self.root = f"hf://buckets/{self.bucket_id}"
80
+ if self.prefix:
81
+ self.root = f"{self.root}/{self.prefix}"
82
+ self._fs = fs
83
+ self.cache_dir = (
84
+ Path(cache_dir)
85
+ if cache_dir is not None
86
+ else (RELAY_CACHE_DIR / _safe_bucket_slug(self.bucket_id))
87
+ )
88
+ if cache_dir is None and self.prefix:
89
+ self.cache_dir = self.cache_dir / self.prefix
90
+ self.cache_dir.mkdir(parents=True, exist_ok=True)
91
+ if register_static_paths is not None:
92
+ register_static_paths([self.cache_dir])
93
+
94
+ @property
95
+ def fs(self) -> BucketFileSystem:
96
+ if self._fs is None:
97
+ from huggingface_hub import HfFileSystem
98
+
99
+ self._fs = HfFileSystem()
100
+ return self._fs
101
+
102
+ def list_scenes(self, limit: int = DEFAULT_SCENE_LIMIT) -> list[dict[str, Any]]:
103
+ try:
104
+ raw = self.fs.cat(f"{self.root}/{RELAY_MANIFEST}")
105
+ manifest = json.loads(raw.decode("utf-8"))
106
+ scenes = manifest.get("scenes", [])
107
+ if not isinstance(scenes, list):
108
+ raise ValueError("relay manifest scenes must be a list")
109
+ return [self._hydrate_scene(scene) for scene in scenes[-limit:]]
110
+ except FileNotFoundError:
111
+ return []
112
+ except (OSError, json.JSONDecodeError, KeyError, TypeError, ValueError) as exc:
113
+ raise BucketRelayError(f"could not read relay bucket {self.bucket_id}: {exc}") from exc
114
+
115
+ def media_url(self, path: str | None) -> str | None:
116
+ if not path:
117
+ return None
118
+ if path.startswith(("http://", "https://", "data:", GRADIO_FILE_ROUTE)):
119
+ return path
120
+ relative = self._relative_media_path(path)
121
+ target = self.cache_dir / relative
122
+ if not target.exists():
123
+ target.parent.mkdir(parents=True, exist_ok=True)
124
+ target.write_bytes(self.fs.cat(f"{self.root}/{relative.as_posix()}"))
125
+ return gradio_file_url(target)
126
+
127
+ def _hydrate_scene(self, scene: dict[str, Any]) -> dict[str, Any]:
128
+ hydrated = copy.deepcopy(scene)
129
+ media = hydrated.get("media")
130
+ if not isinstance(media, dict):
131
+ hydrated["media"] = {}
132
+ return hydrated
133
+ for key in MEDIA_KEYS:
134
+ media[key] = self.media_url(media.get(key))
135
+ return hydrated
136
+
137
+ def _relative_media_path(self, path: str) -> Path:
138
+ value = path.strip().lstrip("/")
139
+ if self.prefix and value.startswith(f"{self.prefix}/"):
140
+ value = value[len(self.prefix) + 1 :]
141
+ relative = Path(value)
142
+ if relative.is_absolute() or ".." in relative.parts:
143
+ raise ValueError(f"unsafe bucket media path: {path}")
144
+ return relative
145
+
146
+
147
+ def prepare_relay_snapshot(
148
+ engine_url: str,
149
+ output_dir: str | Path,
150
+ *,
151
+ limit: int = DEFAULT_SCENE_LIMIT,
152
+ include_private: bool = False,
153
+ client: httpx.Client | None = None,
154
+ ) -> RelaySnapshot:
155
+ """Stage a bucket-ready manifest + media snapshot from the private engine."""
156
+ base_url = engine_url.rstrip("/")
157
+ output = Path(output_dir)
158
+ media_root = output / "media"
159
+ output.mkdir(parents=True, exist_ok=True)
160
+ media_root.mkdir(parents=True, exist_ok=True)
161
+ close_client = client is None
162
+ http = client or httpx.Client(timeout=HTTP_TIMEOUT_S)
163
+ try:
164
+ response = http.get(f"{base_url}/v1/scenes")
165
+ response.raise_for_status()
166
+ scenes = response.json().get("scenes", [])[-limit:]
167
+ published = [
168
+ _stage_scene_media(base_url, output, scene, http)
169
+ for scene in scenes
170
+ if _should_publish_scene(scene, include_private=include_private)
171
+ ]
172
+ finally:
173
+ if close_client:
174
+ http.close()
175
+ manifest = {
176
+ "contract_version": "1.1.0",
177
+ "published_at": datetime.now(timezone.utc).isoformat(),
178
+ "source_engine": base_url,
179
+ "scenes": published,
180
+ }
181
+ manifest_path = output / RELAY_MANIFEST
182
+ manifest_path.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n")
183
+ return RelaySnapshot(output, len(published), manifest_path)
184
+
185
+
186
+ def _should_publish_scene(scene: dict[str, Any], *, include_private: bool) -> bool:
187
+ if include_private:
188
+ return True
189
+ return scene.get("visibility") in PUBLISH_VISIBILITIES
190
+
191
+
192
+ def _stage_scene_media(
193
+ engine_url: str,
194
+ output_dir: Path,
195
+ scene: dict[str, Any],
196
+ client: httpx.Client,
197
+ ) -> dict[str, Any]:
198
+ staged = copy.deepcopy(scene)
199
+ media = staged.get("media")
200
+ if not isinstance(media, dict):
201
+ staged["media"] = {}
202
+ return staged
203
+ scene_dir = _safe_path_segment(str(staged.get("scene_id") or "scene"))
204
+ for key in MEDIA_KEYS:
205
+ media[key] = _download_media(engine_url, output_dir, scene_dir, media.get(key), client)
206
+ return staged
207
+
208
+
209
+ def _download_media(
210
+ engine_url: str,
211
+ output_dir: Path,
212
+ scene_dir: str,
213
+ url: str | None,
214
+ client: httpx.Client,
215
+ ) -> str | None:
216
+ if not url:
217
+ return None
218
+ if url.startswith(("data:", GRADIO_FILE_ROUTE)):
219
+ return None
220
+ absolute = url if url.startswith(("http://", "https://")) else f"{engine_url}/{url.lstrip('/')}"
221
+ relative = _relay_media_path(url, scene_dir)
222
+ target = output_dir / relative
223
+ target.parent.mkdir(parents=True, exist_ok=True)
224
+ response = client.get(absolute)
225
+ response.raise_for_status()
226
+ target.write_bytes(response.content)
227
+ return relative.as_posix()
228
+
229
+
230
+ def _relay_media_path(url: str, scene_dir: str) -> Path:
231
+ parsed = urlparse(url)
232
+ source_path = (parsed.path if parsed.scheme else url.split("?", 1)[0]).lstrip("/")
233
+ if source_path.startswith("media/"):
234
+ relative = Path(source_path)
235
+ else:
236
+ filename = _safe_path_segment(Path(source_path).name or "media.bin")
237
+ relative = Path("media") / scene_dir / filename
238
+ if relative.is_absolute() or ".." in relative.parts:
239
+ raise ValueError(f"unsafe relay media path: {url}")
240
+ return relative
241
+
242
+
243
+ def _safe_path_segment(value: str) -> str:
244
+ cleaned = "".join(ch if ch.isalnum() or ch in ("-", "_", ".") else "-" for ch in value)
245
+ return cleaned.strip(".-") or "item"
src/small_cuts/observability.py ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Optional Sentry observability for demo/runtime failures.
2
+
3
+ No Sentry traffic is sent unless ``SENTRY_DSN`` is configured. Payload scrubbing
4
+ keeps frames, audio, cookies, and auth headers out of events.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ from typing import Any
11
+
12
+ SENTRY_DSN_ENV = "SENTRY_DSN"
13
+ SENTRY_ENV_ENV = "SENTRY_ENVIRONMENT"
14
+ SENTRY_RELEASE_ENV = "SENTRY_RELEASE"
15
+ SENSITIVE_HEADERS = {"authorization", "cookie", "x-api-key", "x-forwarded-for"}
16
+
17
+ _INITIALIZED = False
18
+
19
+
20
+ def init_sentry(dsn: str | None = None, *, sdk: Any | None = None) -> bool:
21
+ global _INITIALIZED
22
+ dsn = dsn if dsn is not None else os.environ.get(SENTRY_DSN_ENV, "").strip()
23
+ if not dsn:
24
+ return False
25
+ if _INITIALIZED:
26
+ return True
27
+ sdk = sdk or _import_sentry_sdk()
28
+ if sdk is None:
29
+ return False
30
+ sdk.init(
31
+ dsn=dsn,
32
+ environment=os.environ.get(SENTRY_ENV_ENV) or os.environ.get("SPACE_ID") or "local",
33
+ release=os.environ.get(SENTRY_RELEASE_ENV) or os.environ.get("SPACE_COMMIT_SHA"),
34
+ send_default_pii=False,
35
+ attach_stacktrace=True,
36
+ traces_sample_rate=0.0,
37
+ before_send=_scrub_event,
38
+ )
39
+ _INITIALIZED = True
40
+ return True
41
+
42
+
43
+ def capture_exception(exc: BaseException, *, sdk: Any | None = None) -> None:
44
+ if not _INITIALIZED and not init_sentry(sdk=sdk):
45
+ return
46
+ sdk = sdk or _import_sentry_sdk()
47
+ if sdk is not None:
48
+ sdk.capture_exception(exc)
49
+
50
+
51
+ def _import_sentry_sdk() -> Any | None:
52
+ try:
53
+ import sentry_sdk
54
+ except ImportError:
55
+ return None
56
+ return sentry_sdk
57
+
58
+
59
+ def _scrub_event(event: dict[str, Any], _hint: dict[str, Any]) -> dict[str, Any]:
60
+ request = event.get("request")
61
+ if isinstance(request, dict):
62
+ request.pop("data", None)
63
+ request.pop("cookies", None)
64
+ headers = request.get("headers")
65
+ if isinstance(headers, dict):
66
+ request["headers"] = {
67
+ key: value for key, value in headers.items() if key.lower() not in SENSITIVE_HEADERS
68
+ }
69
+ return event
70
+
71
+
72
+ def reset_for_tests() -> None:
73
+ global _INITIALIZED
74
+ _INITIALIZED = False
src/small_cuts/viewer.py CHANGED
@@ -37,6 +37,16 @@ from PIL import Image
37
  from . import demo_seed
38
  from ._icons import ICON_CSS
39
  from .frames import pick_key_frame, sample_frames
 
 
 
 
 
 
 
 
 
 
40
  from .styles import DEFAULT_STYLE_KEY, STYLES
41
  from .title_card import derive_title
42
  from .tts import speak
@@ -496,7 +506,17 @@ class EngineClient:
496
  return path if path.startswith("http") else f"{self.base_url}{path}"
497
 
498
 
499
- def shelf_items(scenes: list[dict[str, Any]], client: EngineClient) -> list[tuple[str, str]]:
 
 
 
 
 
 
 
 
 
 
500
  """Gallery payload: POV frame thumbnails captioned with the generated scene title."""
501
  items = []
502
  for scene in scenes:
@@ -508,7 +528,7 @@ def shelf_items(scenes: list[dict[str, Any]], client: EngineClient) -> list[tupl
508
 
509
 
510
  def poll_engine(
511
- client: EngineClient,
512
  scenes_prev: list[dict[str, Any]],
513
  pinned_id: str | None,
514
  playing_id: str | None,
@@ -523,7 +543,8 @@ def poll_engine(
523
  """
524
  try:
525
  scenes = client.list_scenes(limit=SHELF_LIMIT)
526
- except (httpx.HTTPError, KeyError, ValueError) as exc:
 
527
  kind = type(exc).__name__
528
  print(
529
  f"small_cuts.viewer: engine poll failed for {client.base_url}: {kind}: {exc!r}",
@@ -644,7 +665,8 @@ def _go_live_handler(
644
  speech = speak(narration)
645
  scene["duration"] = len(speech.audio) / speech.sample_rate if speech.sample_rate else None
646
  scene["audio_src"] = _write_voice(speech.audio, speech.sample_rate, scene["scene_id"])
647
- except Exception:
 
648
  scene["audio_src"] = None
649
  scenes = [*(scenes or []), scene][-SHELF_LIMIT:]
650
  payload = format_stage(scene)
@@ -994,7 +1016,14 @@ PLAYBACK_SYNC_JS = """
994
  def build_viewer_app() -> gr.Blocks:
995
  """The P1 viewer page. Mode is decided once, at build time, from the env."""
996
  engine_url = os.environ.get(ENGINE_URL_ENV, "").strip()
997
- client = EngineClient(engine_url) if engine_url else None
 
 
 
 
 
 
 
998
  seed = _seed_scenes() if client is None else []
999
 
1000
  if client:
 
37
  from . import demo_seed
38
  from ._icons import ICON_CSS
39
  from .frames import pick_key_frame, sample_frames
40
+ from .hf_relay import (
41
+ DEFAULT_RELAY_PREFIX,
42
+ RELAY_BUCKET_ENV,
43
+ RELAY_PREFIX_ENV,
44
+ BucketRelayError,
45
+ )
46
+ from .hf_relay import (
47
+ BucketSceneClient as _BucketSceneClient,
48
+ )
49
+ from .observability import capture_exception
50
  from .styles import DEFAULT_STYLE_KEY, STYLES
51
  from .title_card import derive_title
52
  from .tts import speak
 
506
  return path if path.startswith("http") else f"{self.base_url}{path}"
507
 
508
 
509
+ class BucketSceneClient(_BucketSceneClient):
510
+ """Bucket relay client wired to Gradio's static file serving."""
511
+
512
+ def __init__(self, *args, **kwargs) -> None:
513
+ kwargs.setdefault("register_static_paths", gr.set_static_paths)
514
+ super().__init__(*args, **kwargs)
515
+
516
+
517
+ def shelf_items(
518
+ scenes: list[dict[str, Any]], client: EngineClient | BucketSceneClient
519
+ ) -> list[tuple[str, str]]:
520
  """Gallery payload: POV frame thumbnails captioned with the generated scene title."""
521
  items = []
522
  for scene in scenes:
 
528
 
529
 
530
  def poll_engine(
531
+ client: EngineClient | BucketSceneClient,
532
  scenes_prev: list[dict[str, Any]],
533
  pinned_id: str | None,
534
  playing_id: str | None,
 
543
  """
544
  try:
545
  scenes = client.list_scenes(limit=SHELF_LIMIT)
546
+ except (httpx.HTTPError, BucketRelayError, KeyError, ValueError) as exc:
547
+ capture_exception(exc)
548
  kind = type(exc).__name__
549
  print(
550
  f"small_cuts.viewer: engine poll failed for {client.base_url}: {kind}: {exc!r}",
 
665
  speech = speak(narration)
666
  scene["duration"] = len(speech.audio) / speech.sample_rate if speech.sample_rate else None
667
  scene["audio_src"] = _write_voice(speech.audio, speech.sample_rate, scene["scene_id"])
668
+ except Exception as exc:
669
+ capture_exception(exc)
670
  scene["audio_src"] = None
671
  scenes = [*(scenes or []), scene][-SHELF_LIMIT:]
672
  payload = format_stage(scene)
 
1016
  def build_viewer_app() -> gr.Blocks:
1017
  """The P1 viewer page. Mode is decided once, at build time, from the env."""
1018
  engine_url = os.environ.get(ENGINE_URL_ENV, "").strip()
1019
+ relay_bucket = os.environ.get(RELAY_BUCKET_ENV, "").strip()
1020
+ relay_prefix = os.environ.get(RELAY_PREFIX_ENV, DEFAULT_RELAY_PREFIX).strip()
1021
+ if engine_url:
1022
+ client: EngineClient | BucketSceneClient | None = EngineClient(engine_url)
1023
+ elif relay_bucket:
1024
+ client = BucketSceneClient(relay_bucket, prefix=relay_prefix or DEFAULT_RELAY_PREFIX)
1025
+ else:
1026
+ client = None
1027
  seed = _seed_scenes() if client is None else []
1028
 
1029
  if client: