| """PAREIDOLIA — Space entrypoint. Everything secretly has a face. |
| |
| Wiring (ARCHITECTURE.md is the contract; the godseed skeleton is the proven base): |
| |
| Menagerie wall (web/) <-- GET / static, zero GPU |
| the séance <-- @app.api(name="awaken") gradio endpoint via |
| @gradio/client — the VISITOR's browser-authenticated |
| request pays the ZeroGPU quota |
| (POST /api/awaken is its mock/dev REST twin, |
| not registered on the zerogpu backend) |
| publish to the wall <-- POST /api/menagerie {record_token}; rate-limited, |
| gate re-checked server-side |
| the wall, paginated <-- GET /api/menagerie newest first |
| one soul, by id <-- GET /api/menagerie/{id} the share page's data twin |
| the share page <-- GET /o/{id} HTML + OG meta; composite |
| card at /media/{id}_og.jpg (made at publish time) |
| live wall updates <-- GET /api/stream SSE 'awakened' broadcasts |
| persisted media <-- GET /media/{id}.(jpg|wav) id->path map only |
| (+ {id}_mutter.wav, {id}_og.jpg siblings) |
| liveness for the watchdog <-- GET /api/health CPU-only; always 200; |
| degraded=true on 2 consecutive backend errors or |
| a wedged ZeroGPU context (server/health.py) |
| |
| Run: python app.py (port from $PORT, default 7860) |
| Backends: PAREIDOLIA_BACKEND = mock | zerogpu (default mock) |
| Sync: PAREIDOLIA_DATASET (+ HF_TOKEN) mirrors menagerie/ to an HF dataset. |
| Identity: PAREIDOLIA_SECRET signs visitor cookies (publish <= 6/hour each). |
| |
| The "her" pattern: the VLM owns taste (what counts as an eye, what the soul |
| sounds like); this file owns facts — bytes, tokens, limits, and what is allowed |
| onto the public wall. Errors and refusals are always poetry, never stack traces. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import asyncio |
| import base64 |
| import hashlib |
| import html |
| import io |
| import logging |
| import os |
| import re |
| import threading |
| import warnings |
|
|
| |
| |
| |
| |
| if os.environ.get("PAREIDOLIA_BACKEND", "").strip().lower() == "zerogpu": |
| os.environ.setdefault("TORCHDYNAMO_DISABLE", "1") |
| try: |
| import spaces |
| except ImportError: |
| pass |
|
|
| from contextlib import asynccontextmanager |
| from pathlib import Path |
| from typing import Any, Optional |
|
|
| from fastapi import FastAPI, HTTPException, Query, Request, Response |
| from fastapi.responses import ( |
| FileResponse, |
| HTMLResponse, |
| JSONResponse, |
| StreamingResponse, |
| ) |
| from fastapi.staticfiles import StaticFiles |
|
|
| from server.health import HealthTracker, classify_exception |
| from server.og import compose_og |
| from server.persistence import PendingStore, PersistenceService |
| from server.ratelimit import ( |
| COOKIE_NAME, |
| DEFAULT_IP_LIMIT, |
| ClientIdentity, |
| RateLimiter, |
| client_ip, |
| ) |
| from server.schemas import AwakenRequest, GateFlags, PublishRequest |
| from server.sse import SSEHub |
| from server.wiring import PipelineLike, make_pipeline |
|
|
| log = logging.getLogger("pareidolia") |
|
|
| ROOT = Path(__file__).resolve().parent |
| WEB_DIR = ROOT / "web" |
| MENAGERIE_DIR = ROOT / "menagerie" |
| SEEDS_RECORDS_DIR = ROOT / "seeds" / "records" |
|
|
| VALID_BACKENDS = ("mock", "zerogpu") |
|
|
| |
| |
| MAX_IMAGE_B64_BYTES = 1_572_864 |
|
|
| |
| OVERSIZE_REASON = ( |
| "That offering is too heavy for the séance table. " |
| "Bring it under a megabyte and a half, and the spirits will look again." |
| ) |
| NEEDS_IMAGE_REASON = "The spirits need a photograph to peer into. The table is bare." |
| BAD_IMAGE_REASON = "The spirits see no photograph here — only static." |
| VAST_IMAGE_REASON = ( |
| "The spirits cannot hold a vision that vast. " |
| "Offer a smaller photograph and they will lean in close." |
| ) |
| SEANCE_FAILED_REASON = ( |
| "The candle guttered mid-séance. Nothing was disturbed; try once more." |
| ) |
| |
| |
| BUSY_REASON = ( |
| "So many have come to wake their things that the spirits are overwhelmed. " |
| "Rest a moment, then try again — or sign in to Hugging Face to bring your " |
| "own candle." |
| ) |
| RATE_LIMIT_REASON = ( |
| "The menagerie admits six souls an hour from one hand. " |
| "Sit with the ones you have woken; the wall will wait." |
| ) |
| TOKEN_UNKNOWN_REASON = ( |
| "The spirits hold no séance by that name — or it has faded with the quarter hour." |
| ) |
| GATE_RECHECK_REASON = "The wall declines this one. The spirits were clear." |
| NOT_FOUND_REASON = "The menagerie holds no such creature." |
| WALL_CROWDED_REASON = ( |
| "The wall is thick with watchers tonight. Linger a moment, then look again." |
| ) |
|
|
| MEDIA_NAME_RE = re.compile(r"^([A-Za-z0-9_-]{4,64})\.(jpg|wav)$") |
| MEDIA_TYPES = {"jpg": "image/jpeg", "wav": "audio/wav"} |
|
|
| |
| |
| MAX_IMAGE_SIDE = 1024 |
| JPEG_QUALITY = 88 |
|
|
| |
| |
| |
| |
| MAX_PIXELS = MAX_IMAGE_SIDE * MAX_IMAGE_SIDE * 4 |
| try: |
| from PIL import Image as _PILImage |
|
|
| |
| |
| |
| _PILImage.MAX_IMAGE_PIXELS = MAX_PIXELS |
| warnings.filterwarnings("ignore", category=_PILImage.DecompressionBombWarning) |
| except ImportError: |
| pass |
|
|
|
|
| def _backend_name() -> str: |
| name = os.environ.get("PAREIDOLIA_BACKEND", "mock").strip().lower() or "mock" |
| if name not in VALID_BACKENDS: |
| log.warning("unknown PAREIDOLIA_BACKEND=%r; falling back to mock", name) |
| name = "mock" |
| return name |
|
|
|
|
| |
| def _set_identity_cookie(response: Response, request: Request, value: str) -> None: |
| """Spaces serve over https inside an iframe (cross-site -> SameSite=None+Secure); |
| local dev is plain http (Lax, not Secure).""" |
| scheme = request.headers.get("x-forwarded-proto", request.url.scheme) |
| secure = scheme == "https" |
| response.set_cookie( |
| COOKIE_NAME, |
| value, |
| max_age=365 * 24 * 3600, |
| path="/", |
| httponly=True, |
| secure=secure, |
| samesite="none" if secure else "lax", |
| ) |
|
|
|
|
| def _decode_image(image_b64: str): |
| """base64 -> validated, bounded RGB PIL image. Raises poetic HTTPExceptions: |
| 413 oversize, 400 undecodable, 422 missing. PIL import stays local so the |
| transport module imports clean even in odd environments.""" |
| if not image_b64 or not image_b64.strip(): |
| raise HTTPException(status_code=422, detail=NEEDS_IMAGE_REASON) |
| if len(image_b64) > MAX_IMAGE_B64_BYTES: |
| raise HTTPException(status_code=413, detail=OVERSIZE_REASON) |
| payload = image_b64.strip() |
| if payload.startswith("data:"): |
| payload = payload.partition(",")[2] |
| try: |
| raw = base64.b64decode(payload, validate=True) |
| except Exception as exc: |
| raise HTTPException(status_code=400, detail=BAD_IMAGE_REASON) from exc |
| try: |
| from PIL import Image |
|
|
| image = Image.open(io.BytesIO(raw)) |
| |
| |
| |
| width, height = image.size |
| if width * height > MAX_PIXELS: |
| raise HTTPException(status_code=413, detail=VAST_IMAGE_REASON) |
| image.load() |
| image = image.convert("RGB") |
| except HTTPException: |
| raise |
| except Exception as exc: |
| |
| |
| raise HTTPException(status_code=400, detail=BAD_IMAGE_REASON) from exc |
| if max(image.size) > MAX_IMAGE_SIDE: |
| image.thumbnail((MAX_IMAGE_SIDE, MAX_IMAGE_SIDE)) |
| return image |
|
|
|
|
| def _encode_jpeg(image) -> bytes: |
| buf = io.BytesIO() |
| image.save(buf, format="JPEG", quality=JPEG_QUALITY) |
| return buf.getvalue() |
|
|
|
|
| |
| def _public_base(request: Request) -> str: |
| """Absolute origin for OG tags (crawlers refuse relative og:image URLs). |
| On Spaces SPACE_HOST is the public hostname; locally fall back to the |
| forwarded/request host.""" |
| space_host = os.environ.get("SPACE_HOST", "").strip() |
| if space_host: |
| return f"https://{space_host}" |
| host = request.headers.get("x-forwarded-host") or request.headers.get("host") |
| if not host: |
| return "" |
| scheme = request.headers.get("x-forwarded-proto", request.url.scheme or "https") |
| return f"{scheme}://{host}" |
|
|
|
|
| def _share_display_name(record: dict[str, Any]) -> str: |
| """Mirror web/main.js displayName: 'the rusted fire hydrant'.""" |
| name = " ".join( |
| str(p) for p in (record.get("condition"), record.get("object")) if p |
| ) |
| return f"the {name}".lower() if name else "an awakened thing" |
|
|
|
|
| def _share_page(record: dict[str, Any], base: str, og_image: str) -> str: |
| """One published soul as a standalone, crawler-friendly HTML page. |
| |
| Server-persisted records only (the wall's no-client-HTML rule holds: |
| every interpolated field is escaped). Inline styles on purpose — the page |
| must unfurl and render even if the SPA assets change underneath it.""" |
| rid = str(record.get("id", "")) |
| name = _share_display_name(record) |
| lines = record.get("lines") or {} |
| grudge = str(lines.get("grudge") or "").strip() |
| mutter = str(lines.get("mutter") or "").strip() |
| title = f"{name} — PAREIDOLIA" |
| description = grudge or "It has a face. It has a grudge. It will tell you." |
| url = f"{base}/o/{rid}" |
| image_url = record.get("image_url", f"/media/{rid}.jpg") |
| audio_url = record.get("audio_url", "") |
| mutter_audio = record.get("mutter_audio", "") |
|
|
| e = html.escape |
| mutter_block = ( |
| f'<p class="mutter">…it also mutters: <em>{e(mutter)}</em></p>' if mutter else "" |
| ) |
| audio_block = ( |
| f'<audio controls preload="none" src="{e(audio_url, quote=True)}"></audio>' |
| if audio_url |
| else "" |
| ) |
| mutter_audio_block = ( |
| f'<audio controls preload="none" src="{e(mutter_audio, quote=True)}"></audio>' |
| if mutter_audio |
| else "" |
| ) |
| return f"""<!doctype html> |
| <html lang="en"> |
| <head> |
| <meta charset="utf-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1"> |
| <title>{e(title)}</title> |
| <meta name="description" content="{e(description, quote=True)}"> |
| <meta property="og:type" content="website"> |
| <meta property="og:site_name" content="PAREIDOLIA"> |
| <meta property="og:title" content="{e(title, quote=True)}"> |
| <meta property="og:description" content="{e(description, quote=True)}"> |
| <meta property="og:url" content="{e(url, quote=True)}"> |
| <meta property="og:image" content="{e(base + og_image, quote=True)}"> |
| <meta property="og:image:width" content="1200"> |
| <meta property="og:image:height" content="630"> |
| <meta name="twitter:card" content="summary_large_image"> |
| <meta name="twitter:title" content="{e(title, quote=True)}"> |
| <meta name="twitter:description" content="{e(description, quote=True)}"> |
| <meta name="twitter:image" content="{e(base + og_image, quote=True)}"> |
| <style> |
| @font-face {{ font-family: "Cormorant Garamond"; font-style: normal; font-weight: 300 700; |
| src: url("/vendor/fonts/cormorant-garamond-latin.woff2") format("woff2"); }} |
| @font-face {{ font-family: "Cormorant Garamond"; font-style: italic; font-weight: 300 700; |
| src: url("/vendor/fonts/cormorant-garamond-latin-italic.woff2") format("woff2"); }} |
| body {{ margin: 0; background: #0b0a09; color: #efe6d4; |
| font-family: "Cormorant Garamond", "Iowan Old Style", Georgia, serif; |
| display: grid; place-items: center; min-height: 100vh; }} |
| main {{ max-width: 640px; padding: 48px 24px 64px; text-align: center; }} |
| .label {{ color: #e8a849; letter-spacing: 0.14em; text-transform: lowercase; |
| font-size: 14px; }} |
| img.photo {{ width: 100%; max-width: 520px; border: 1px solid rgba(232,168,73,.22); |
| border-radius: 4px; margin: 24px 0 18px; }} |
| h1 {{ font-size: 42px; font-weight: 500; margin: 0 0 10px; }} |
| blockquote {{ font-style: italic; font-size: 26px; margin: 8px 0 16px; }} |
| .mutter {{ color: rgba(239,230,212,.58); font-size: 18px; margin: 8px 0; }} |
| audio {{ width: 100%; max-width: 420px; margin: 8px 0; filter: sepia(.4) saturate(.6); }} |
| a.cta {{ display: inline-block; margin-top: 28px; color: #e8a849; |
| text-decoration: none; border-bottom: 1px solid rgba(232,168,73,.4); |
| font-size: 20px; }} |
| footer {{ margin-top: 36px; color: rgba(239,230,212,.32); font-style: italic; |
| font-size: 16px; }} |
| </style> |
| </head> |
| <body> |
| <main> |
| <p class="label">p a r e i d o l i a</p> |
| <img class="photo" src="{e(image_url, quote=True)}" alt="{e(name, quote=True)}"> |
| <h1>{e(name)}</h1> |
| <blockquote>“{e(grudge)}”</blockquote> |
| {audio_block} |
| {mutter_block} |
| {mutter_audio_block} |
| <a class="cta" href="/">step into the menagerie →</a> |
| <footer>everything secretly has a face.</footer> |
| </main> |
| </body> |
| </html>""" |
|
|
|
|
| |
| def create_app( |
| *, |
| pipeline: Optional[PipelineLike] = None, |
| persistence: Optional[PersistenceService] = None, |
| pending: Optional[PendingStore] = None, |
| hub: Optional[SSEHub] = None, |
| rate_limiter: Optional[RateLimiter] = None, |
| ip_rate_limiter: Optional[RateLimiter] = None, |
| identity: Optional[ClientIdentity] = None, |
| health: Optional[HealthTracker] = None, |
| menagerie_dir: Optional[Path | str] = None, |
| backend: Optional[str] = None, |
| mount_web: bool = True, |
| ) -> FastAPI: |
| """Build the ASGI app. With no arguments this wires the real (env-selected) |
| backend; tests inject fakes for everything mind/cv-shaped.""" |
| backend_name = backend or _backend_name() |
| pipeline = pipeline if pipeline is not None else make_pipeline(backend_name) |
| persistence = ( |
| persistence |
| if persistence is not None |
| else PersistenceService( |
| menagerie_dir if menagerie_dir is not None else MENAGERIE_DIR, |
| seeds_dir=SEEDS_RECORDS_DIR, |
| ) |
| ) |
| pending = pending if pending is not None else PendingStore() |
| hub = hub if hub is not None else SSEHub() |
| rate_limiter = rate_limiter or RateLimiter() |
| ip_rate_limiter = ip_rate_limiter or RateLimiter(limit=DEFAULT_IP_LIMIT) |
| identity = identity or ClientIdentity() |
| health = health or HealthTracker() |
|
|
| @asynccontextmanager |
| async def lifespan(app: FastAPI): |
| yield |
| await persistence.drain() |
|
|
| |
| |
| |
| |
| |
| try: |
| import gradio as gr |
|
|
| app: FastAPI = gr.Server(title="PAREIDOLIA") |
| app.router.lifespan_context = lifespan |
| except Exception: |
| app = FastAPI( |
| title="PAREIDOLIA", docs_url=None, redoc_url=None, lifespan=lifespan |
| ) |
| app.state.persistence = persistence |
| app.state.pending = pending |
| app.state.hub = hub |
| app.state.health = health |
|
|
| async def ensure_ready() -> None: |
| """Boot persistence lazily on the serving loop (godseed's ensure idiom: |
| launch() does not honor an injected lifespan, so the first request — |
| or the warm-boot thread in _serve — does the waking). Idempotent and |
| thread-safe; the dataset restore runs off the event loop.""" |
| if not persistence.booted: |
| await asyncio.to_thread(persistence.boot) |
|
|
| |
| async def _awaken(image_b64: str) -> dict[str, Any]: |
| """§2 pipeline: decode -> medium -> gate -> snap -> TTS -> pending token. |
| Refusals are HTTP 200 + {refused: true}; only transport sins get codes.""" |
| await ensure_ready() |
| image = _decode_image(image_b64) |
| try: |
| outcome = await asyncio.to_thread(pipeline.run, image) |
| except Exception as exc: |
| |
| |
| |
| |
| |
| |
| |
| |
| status = classify_exception(exc) |
| if status == "busy": |
| health.record("busy") |
| log.warning("séance backpressure (busy/quota): %s", exc) |
| return { |
| "refused": True, |
| "reason": BUSY_REASON, |
| "quota": True, |
| "retry": True, |
| } |
| health.record(status) |
| log.exception("séance pipeline failed (%s)", status) |
| raise HTTPException( |
| status_code=500, detail=SEANCE_FAILED_REASON |
| ) from exc |
| if outcome.refused: |
| health.record("refused") |
| return {"refused": True, "reason": outcome.reason or SEANCE_FAILED_REASON} |
| if outcome.result is None or outcome.grudge_wav is None: |
| |
| |
| health.record("backend_error") |
| return {"refused": True, "reason": outcome.reason or SEANCE_FAILED_REASON} |
| health.record("ok") |
| result = outcome.result |
| jpeg = _encode_jpeg(image) |
| record: dict[str, Any] = { |
| "object": result.object, |
| "material": result.material, |
| "condition": result.condition, |
| "setting": result.setting, |
| "persona": result.persona.model_dump(), |
| "features": [f.model_dump() for f in outcome.features], |
| "lines": result.lines.model_dump(), |
| "critique": result.critique, |
| "image_sha256": hashlib.sha256(jpeg).hexdigest(), |
| "backend": backend_name, |
| } |
| if getattr(outcome, "punchup", None) is not None: |
| |
| |
| record["punchup"] = outcome.punchup |
| if getattr(outcome, "geometry", None) is not None: |
| |
| |
| |
| record["geometry"] = outcome.geometry |
| token = pending.put( |
| record=record, |
| image_jpeg=jpeg, |
| grudge_wav=outcome.grudge_wav, |
| gate=result.gate.model_dump(), |
| |
| |
| mutter_wav=getattr(outcome, "mutter_wav", None), |
| ) |
| return { |
| "refused": False, |
| "record_token": token, |
| "record": { |
| **record, |
| "grudge_audio_b64": base64.b64encode(outcome.grudge_wav).decode( |
| "ascii" |
| ), |
| }, |
| } |
|
|
| |
| |
| if hasattr(app, "api"): |
|
|
| @app.api(name="awaken") |
| async def awaken(image_b64: str = "") -> dict: |
| try: |
| return await _awaken(image_b64) |
| except HTTPException as exc: |
| |
| |
| return { |
| "refused": True, |
| "reason": str(exc.detail), |
| "status": exc.status_code, |
| } |
| except Exception: |
| log.exception("awaken failed") |
| return {"refused": True, "reason": SEANCE_FAILED_REASON, "status": 500} |
|
|
| |
| |
| |
| if backend_name != "zerogpu": |
|
|
| @app.post("/api/awaken") |
| async def awaken_rest(payload: AwakenRequest) -> JSONResponse: |
| return JSONResponse(await _awaken(payload.image_b64)) |
|
|
| |
| @app.get("/api/health") |
| async def health_check() -> JSONResponse: |
| """CPU-only liveness for tools/watchdog.py (laptop-side): per-process |
| awaken counters + the degraded flag that detects the wedged-ZeroGPU |
| failure mode. No auth, no GPU, never boots persistence — this must |
| answer instantly even when the backend is on fire. Always HTTP 200: |
| the ``degraded`` flag carries the signal, not the status code.""" |
| return JSONResponse(health.snapshot(backend_name)) |
|
|
| |
| @app.post("/api/menagerie") |
| async def publish(payload: PublishRequest, request: Request) -> JSONResponse: |
| await ensure_ready() |
| cid, new_cookie = identity.resolve(request) |
|
|
| |
| |
| |
| |
| |
| |
| if pending.peek(payload.record_token) is None: |
| raise HTTPException(status_code=404, detail=TOKEN_UNKNOWN_REASON) |
|
|
| ip_ok, ip_retry = ip_rate_limiter.hit(f"ip:{client_ip(request)}") |
| allowed, retry_after = rate_limiter.hit(cid) if ip_ok else (False, ip_retry) |
| if not allowed: |
| raise HTTPException( |
| status_code=429, |
| detail=RATE_LIMIT_REASON, |
| headers={"Retry-After": str(int(retry_after) + 1)}, |
| ) |
|
|
| held = pending.claim(payload.record_token) |
| if held is None: |
| raise HTTPException(status_code=404, detail=TOKEN_UNKNOWN_REASON) |
|
|
| |
| |
| if not GateFlags.model_validate(held.gate).passes(): |
| raise HTTPException(status_code=403, detail=GATE_RECHECK_REASON) |
|
|
| |
| |
| |
| og_jpeg: Optional[bytes] = None |
| try: |
| og_jpeg = await asyncio.to_thread(compose_og, held.image_jpeg, held.record) |
| except Exception: |
| log.exception("og composite failed; publishing without it") |
|
|
| record = await persistence.publish( |
| held.record, |
| held.image_jpeg, |
| held.grudge_wav, |
| mutter_wav=getattr(held, "mutter_wav", None), |
| og_jpeg=og_jpeg, |
| ) |
| hub.publish({"type": "awakened", "record": record}) |
| |
| |
| |
| out = JSONResponse(record) |
| if new_cookie: |
| _set_identity_cookie(out, request, new_cookie) |
| return out |
|
|
| |
| @app.get("/api/menagerie") |
| async def menagerie_index( |
| offset: int = Query(default=0, ge=0), |
| limit: int = Query(default=60, ge=1, le=200), |
| ) -> JSONResponse: |
| await ensure_ready() |
| records, total = persistence.page(offset, limit) |
| return JSONResponse( |
| { |
| "records": records, |
| "total": total, |
| "offset": offset, |
| "limit": limit, |
| "awake_count": total, |
| } |
| ) |
|
|
| |
| @app.get("/api/menagerie/{record_id}") |
| async def menagerie_record(record_id: str) -> JSONResponse: |
| """One published record by id (the share page's data twin). The id is |
| only ever a dict key — no client string becomes a path.""" |
| await ensure_ready() |
| record = persistence.find(record_id) |
| if record is None: |
| raise HTTPException(status_code=404, detail=NOT_FOUND_REASON) |
| return JSONResponse(record) |
|
|
| |
| @app.get("/o/{record_id}", include_in_schema=False) |
| async def share_page(record_id: str, request: Request) -> HTMLResponse: |
| """The share page: one soul, standalone, with OG meta so the link |
| unfurls (composite card when {id}_og.jpg exists, the photo otherwise). |
| Published records only — pending souls have no public page.""" |
| await ensure_ready() |
| record = persistence.find(record_id) |
| if record is None: |
| raise HTTPException(status_code=404, detail=NOT_FOUND_REASON) |
| og = persistence.media_path(f"{record_id}_og", "jpg") |
| og_image = ( |
| f"/media/{record_id}_og.jpg" |
| if og is not None and og.is_file() |
| else record.get("image_url", f"/media/{record_id}.jpg") |
| ) |
| page = _share_page(record, _public_base(request), og_image) |
| return HTMLResponse(page, headers={"Cache-Control": "public, max-age=300"}) |
|
|
| |
| @app.get("/api/stream") |
| async def stream( |
| request: Request, |
| limit: Optional[int] = Query( |
| default=None, |
| ge=1, |
| le=10_000, |
| description="debug/test aid: close the stream after N events", |
| ), |
| ) -> StreamingResponse: |
| await ensure_ready() |
| |
| |
| |
| |
| queue = hub.subscribe(key=f"ip:{client_ip(request)}") |
| if queue is None: |
| raise HTTPException( |
| status_code=503, |
| detail=WALL_CROWDED_REASON, |
| headers={"Retry-After": "15"}, |
| ) |
| initial = [{"type": "hello", "awake_count": persistence.count()}] |
| return StreamingResponse( |
| hub.event_stream(initial_events=initial, limit=limit, queue=queue), |
| media_type="text/event-stream", |
| headers={ |
| "Cache-Control": "no-cache", |
| "X-Accel-Buffering": "no", |
| "Connection": "keep-alive", |
| }, |
| ) |
|
|
| |
| @app.get("/media/{name}", include_in_schema=False) |
| async def media(name: str) -> FileResponse: |
| """Persisted menagerie media. Traversal-safe by construction: the name |
| must match the mint alphabet, and the id resolves through the |
| server-built id->path map only — no client string ever becomes a path.""" |
| await ensure_ready() |
| match = MEDIA_NAME_RE.fullmatch(name) |
| if match is None: |
| raise HTTPException(status_code=404, detail=NOT_FOUND_REASON) |
| path = persistence.media_path(match.group(1), match.group(2)) |
| if path is None or not path.is_file(): |
| raise HTTPException(status_code=404, detail=NOT_FOUND_REASON) |
| return FileResponse( |
| path, |
| media_type=MEDIA_TYPES[match.group(2)], |
| headers={"Cache-Control": "public, max-age=31536000, immutable"}, |
| ) |
|
|
| |
| def _mount_static() -> None: |
| """Root static mount. A "/" catch-all registered BEFORE gr.Server.launch() |
| shadows the /gradio_api routes gradio adds at launch time (godseed, |
| verified June 12: the launch self-check 404s and the app dies). So the |
| mount is deferred: _serve() calls this AFTER launch; the plain-FastAPI |
| path mounts immediately below.""" |
| if WEB_DIR.is_dir(): |
| |
| |
| |
| |
| app.router.routes[:] = [ |
| r for r in app.router.routes if getattr(r, "path", None) != "/" |
| ] |
| app.mount("/", StaticFiles(directory=str(WEB_DIR), html=True), name="web") |
| else: |
| log.warning("web/ not found at %s; serving API only", WEB_DIR) |
|
|
| @app.get("/", include_in_schema=False) |
| async def root_placeholder() -> JSONResponse: |
| return JSONResponse({"ok": True, "hint": "frontend not built"}) |
|
|
| app.state.mount_static = _mount_static |
| if mount_web and not hasattr(app, "launch"): |
| _mount_static() |
|
|
| return app |
|
|
|
|
| |
| def _serve(application: FastAPI) -> None: |
| port = int(os.environ.get("PORT", os.environ.get("GRADIO_SERVER_PORT", 7860))) |
| log.info("PAREIDOLIA listening on http://0.0.0.0:%d", port) |
| if hasattr(application, "launch"): |
| |
| |
| application.launch( |
| server_name="0.0.0.0", |
| server_port=port, |
| show_error=False, |
| prevent_thread_lock=True, |
| ) |
| mount_static = getattr(application.state, "mount_static", None) |
| if mount_static is not None: |
| mount_static() |
| |
| |
| persistence = getattr(application.state, "persistence", None) |
| if persistence is not None and not persistence.booted: |
| threading.Thread(target=persistence.boot, daemon=True).start() |
| import time as _time |
|
|
| while True: |
| _time.sleep(3600) |
| else: |
| import uvicorn |
|
|
| uvicorn.run(application, host="0.0.0.0", port=port) |
|
|
|
|
| |
| |
| if __name__ == "__main__" or os.environ.get("SPACE_ID"): |
| logging.basicConfig( |
| level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s" |
| ) |
| _serve(create_app()) |
|
|