polats Claude Opus 4.8 (1M context) commited on
Commit
ca12a3d
·
1 Parent(s): 710dcf3

Persist portrait/voice/media server-side (durable in the HF Space iframe)

Browse files

The client cached portrait/voice blobs in browser IndexedDB, which is blocked/
evicted inside HuggingFace's cross-origin Space iframe — so created heroes lost
their portrait + voice on reload (the persona record itself survived via
localStorage, which the iframe keeps).

Fix: mirror media blobs SERVER-SIDE, keyed by the same id the client already
holds in localStorage. Same-origin fetch works in the iframe; only browser
storage is partitioned.

- app.py: POST/GET/DELETE /api/blob/{store}/{id} (portraits, voices, skillIcons,
skillArt, chatAudio). Path-traversal-guarded ids; content-type preserved via a
sidecar. Stored under HF persistent storage (/data) when mounted, else a local
user_data/ dir (override TINY_DATA_DIR).
- personaStore.js: _put writes IDB (fast cache) + server (durable); _get reads
IDB first, falls back to the server and re-caches. Covers all media stores.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files changed (3) hide show
  1. .gitignore +1 -0
  2. app.py +71 -0
  3. web/personaStore.js +25 -3
.gitignore CHANGED
@@ -5,3 +5,4 @@ __pycache__/
5
  .venv-acestep/
6
  logs/
7
  outputs/
 
 
5
  .venv-acestep/
6
  logs/
7
  outputs/
8
+ user_data/
app.py CHANGED
@@ -18,6 +18,8 @@ import json
18
  import asyncio
19
  import json as _json
20
  import os
 
 
21
  import threading
22
  import time
23
 
@@ -797,6 +799,75 @@ async def api_music(request: Request):
797
  return Response(wav, media_type="audio/wav", headers={"Cache-Control": "no-store"})
798
 
799
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
800
  # ── Persona portraits (image generation) ─────────────────────────────────────
801
  # Mirrors the voice path: TINY_IMAGE_MODE=local runs the OPEN WEIGHTS on your GPU
802
  # (Z-Image-Turbo, 6B, ~12 GB bf16 — coexists with the TTS model on a 24 GB card);
 
18
  import asyncio
19
  import json as _json
20
  import os
21
+ import pathlib
22
+ import re
23
  import threading
24
  import time
25
 
 
799
  return Response(wav, media_type="audio/wav", headers={"Cache-Control": "no-store"})
800
 
801
 
802
+ # ── Durable media store (portraits / voices / skill art / chat audio) ─────────
803
+ # The client caches these blobs in browser IndexedDB, but IndexedDB is blocked/evicted when the
804
+ # Space runs inside HuggingFace's cross-origin <iframe> — so created heroes lose their portrait +
805
+ # voice on reload (the persona record itself survives because it lives in localStorage, which the
806
+ # iframe keeps). Mirror the blobs SERVER-SIDE, keyed by the id the client already holds in
807
+ # localStorage. Same-origin fetch works fine in the iframe; only browser *storage* is partitioned.
808
+ # Stored on disk: HF persistent storage (/data) when mounted (survives rebuilds), else a local dir
809
+ # (per-container). Override with TINY_DATA_DIR.
810
+ _DATA_DIR = pathlib.Path(
811
+ os.environ.get("TINY_DATA_DIR")
812
+ or ("/data" if os.path.isdir("/data") and os.access("/data", os.W_OK)
813
+ else os.path.join(os.path.dirname(os.path.abspath(__file__)), "user_data"))
814
+ )
815
+ _BLOB_STORES = {"portraits", "voices", "skillIcons", "skillArt", "chatAudio"}
816
+ _BLOB_ID_RE = re.compile(r"^[A-Za-z0-9_.-]{1,128}$")
817
+
818
+
819
+ def _blob_path(store: str, blob_id: str):
820
+ """Validated path for a media blob, or None if the store/id is invalid (path-traversal guard)."""
821
+ if store not in _BLOB_STORES or not _BLOB_ID_RE.match(blob_id or ""):
822
+ return None
823
+ d = _DATA_DIR / "media" / store
824
+ d.mkdir(parents=True, exist_ok=True)
825
+ return d / blob_id
826
+
827
+
828
+ def _ct_path(p: pathlib.Path):
829
+ return pathlib.Path(str(p) + ".ct") # sidecar holding the blob's content-type
830
+
831
+
832
+ @fastapi_app.post("/api/blob/{store}/{blob_id}")
833
+ async def put_blob(store: str, blob_id: str, request: Request):
834
+ p = _blob_path(store, blob_id)
835
+ if p is None:
836
+ return Response("bad store/id", status_code=400)
837
+ data = await request.body()
838
+ if not data:
839
+ return Response("empty body", status_code=400)
840
+ ct = request.headers.get("content-type") or "application/octet-stream"
841
+ try:
842
+ await asyncio.to_thread(p.write_bytes, data)
843
+ await asyncio.to_thread(_ct_path(p).write_text, ct)
844
+ except Exception as e: # noqa: BLE001
845
+ return Response(f"save failed: {e}", status_code=500)
846
+ return Response(status_code=204)
847
+
848
+
849
+ @fastapi_app.get("/api/blob/{store}/{blob_id}")
850
+ async def get_blob(store: str, blob_id: str):
851
+ p = _blob_path(store, blob_id)
852
+ if p is None or not p.exists():
853
+ return Response(status_code=404)
854
+ ctp = _ct_path(p)
855
+ ct = ctp.read_text().strip() if ctp.exists() else "application/octet-stream"
856
+ return Response(p.read_bytes(), media_type=ct, headers={"Cache-Control": "no-store"})
857
+
858
+
859
+ @fastapi_app.delete("/api/blob/{store}/{blob_id}")
860
+ async def del_blob(store: str, blob_id: str):
861
+ p = _blob_path(store, blob_id)
862
+ if p is not None and p.exists():
863
+ try:
864
+ p.unlink()
865
+ _ct_path(p).unlink(missing_ok=True)
866
+ except Exception: # noqa: BLE001
867
+ pass
868
+ return Response(status_code=204)
869
+
870
+
871
  # ── Persona portraits (image generation) ─────────────────────────────────────
872
  # Mirrors the voice path: TINY_IMAGE_MODE=local runs the OPEN WEIGHTS on your GPU
873
  # (Z-Image-Turbo, 6B, ~12 GB bf16 — coexists with the TTS model on a 24 GB card);
web/personaStore.js CHANGED
@@ -154,20 +154,42 @@ function db() {
154
  }
155
  return _dbp
156
  }
157
- async function _put(store, id, blob) {
 
 
 
 
158
  try {
159
  const d = await db()
160
  await new Promise((res, rej) => { const t = d.transaction(store, 'readwrite'); t.objectStore(store).put(blob, id); t.oncomplete = res; t.onerror = () => rej(t.error) })
161
- } catch { /* best-effort */ }
162
  }
163
- async function _get(store, id) {
164
  try {
165
  const d = await db()
166
  return await new Promise((res) => { const t = d.transaction(store, 'readonly'); const q = t.objectStore(store).get(id); q.onsuccess = () => res(q.result || null); q.onerror = () => res(null) })
167
  } catch { return null }
168
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
169
  async function _del(store, id) {
170
  try { const d = await db(); d.transaction(store, 'readwrite').objectStore(store).delete(id) } catch { /* ignore */ }
 
171
  }
172
  export const putAudio = (id, blob) => _put(STORE, id, blob)
173
  export const getAudio = (id) => _get(STORE, id)
 
154
  }
155
  return _dbp
156
  }
157
+ // IndexedDB is a FAST LOCAL CACHE only — it's blocked/evicted inside the HF Space's cross-origin
158
+ // iframe. The durable copy lives SERVER-SIDE (/api/blob), keyed by the same id. Same-origin fetch
159
+ // works in the iframe; only browser storage is partitioned. So: write both (IDB best-effort +
160
+ // server durable); read IDB first, fall back to the server and re-cache.
161
+ async function _idbPut(store, id, blob) {
162
  try {
163
  const d = await db()
164
  await new Promise((res, rej) => { const t = d.transaction(store, 'readwrite'); t.objectStore(store).put(blob, id); t.oncomplete = res; t.onerror = () => rej(t.error) })
165
+ } catch { /* IDB unavailable (iframe) — server copy covers it */ }
166
  }
167
+ async function _idbGet(store, id) {
168
  try {
169
  const d = await db()
170
  return await new Promise((res) => { const t = d.transaction(store, 'readonly'); const q = t.objectStore(store).get(id); q.onsuccess = () => res(q.result || null); q.onerror = () => res(null) })
171
  } catch { return null }
172
  }
173
+ async function _serverPut(store, id, blob) {
174
+ try { await fetch(`/api/blob/${store}/${encodeURIComponent(id)}`, { method: 'POST', headers: { 'Content-Type': blob.type || 'application/octet-stream' }, body: blob }) } catch { /* offline / best-effort */ }
175
+ }
176
+ async function _serverGet(store, id) {
177
+ try { const r = await fetch(`/api/blob/${store}/${encodeURIComponent(id)}`); if (!r.ok) return null; const b = await r.blob(); return b && b.size ? b : null } catch { return null }
178
+ }
179
+ async function _put(store, id, blob) {
180
+ await _idbPut(store, id, blob)
181
+ await _serverPut(store, id, blob)
182
+ }
183
+ async function _get(store, id) {
184
+ const local = await _idbGet(store, id)
185
+ if (local) return local
186
+ const remote = await _serverGet(store, id)
187
+ if (remote) { _idbPut(store, id, remote); return remote } // re-cache for next time
188
+ return null
189
+ }
190
  async function _del(store, id) {
191
  try { const d = await db(); d.transaction(store, 'readwrite').objectStore(store).delete(id) } catch { /* ignore */ }
192
+ try { fetch(`/api/blob/${store}/${encodeURIComponent(id)}`, { method: 'DELETE' }) } catch { /* ignore */ }
193
  }
194
  export const putAudio = (id, blob) => _put(STORE, id, blob)
195
  export const getAudio = (id) => _get(STORE, id)