Spaces:
Running
Persist portrait/voice/media server-side (durable in the HF Space iframe)
Browse filesThe 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>
- .gitignore +1 -0
- app.py +71 -0
- web/personaStore.js +25 -3
|
@@ -5,3 +5,4 @@ __pycache__/
|
|
| 5 |
.venv-acestep/
|
| 6 |
logs/
|
| 7 |
outputs/
|
|
|
|
|
|
| 5 |
.venv-acestep/
|
| 6 |
logs/
|
| 7 |
outputs/
|
| 8 |
+
user_data/
|
|
@@ -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);
|
|
@@ -154,20 +154,42 @@ function db() {
|
|
| 154 |
}
|
| 155 |
return _dbp
|
| 156 |
}
|
| 157 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 { /*
|
| 162 |
}
|
| 163 |
-
async function
|
| 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)
|