Spaces:
Paused
Paused
Shereen Lee commited on
Commit ·
b685c13
1
Parent(s): bb628b8
merge: SSE refactor + ZeroGPU img2img watercolour develop
Browse files- app.py +251 -140
- index.html +30 -15
- packages.txt +4 -0
- requirements.txt +1 -1
app.py
CHANGED
|
@@ -61,8 +61,8 @@ import threading
|
|
| 61 |
import time
|
| 62 |
import uuid
|
| 63 |
|
| 64 |
-
from fastapi import HTTPException, Request
|
| 65 |
-
from fastapi.responses import FileResponse, HTMLResponse, Response
|
| 66 |
|
| 67 |
from gradio import Server
|
| 68 |
|
|
@@ -2333,7 +2333,7 @@ async def put_room_state(room_id: str, request: Request):
|
|
| 2333 |
return {"version": new_version, "conflict": False}
|
| 2334 |
|
| 2335 |
|
| 2336 |
-
# ---------------------------
|
| 2337 |
|
| 2338 |
_PALETTE = [
|
| 2339 |
"#7c5cff", "#e9698f", "#2bb673", "#f2994a", "#3aa0ff",
|
|
@@ -2378,80 +2378,140 @@ NON_RELAY_EVENTS = {
|
|
| 2378 |
}
|
| 2379 |
|
| 2380 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2381 |
class Manager:
|
| 2382 |
def __init__(self):
|
| 2383 |
-
self.rooms = {} # room_id -> {
|
| 2384 |
-
self.pending = {} # room_id -> {
|
| 2385 |
|
| 2386 |
-
|
| 2387 |
-
|
| 2388 |
-
self.rooms.setdefault(room_id, {})[
|
| 2389 |
|
| 2390 |
-
def
|
| 2391 |
-
|
| 2392 |
-
|
| 2393 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2394 |
|
| 2395 |
-
|
| 2396 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2397 |
|
| 2398 |
-
def
|
|
|
|
|
|
|
|
|
|
| 2399 |
p = self.pending.get(room_id)
|
| 2400 |
-
if p and
|
| 2401 |
-
del p[
|
| 2402 |
if not p:
|
| 2403 |
self.pending.pop(room_id, None)
|
| 2404 |
|
| 2405 |
def find_pending(self, room_id, session_id):
|
| 2406 |
-
"""Return
|
| 2407 |
-
|
| 2408 |
-
|
| 2409 |
-
|
| 2410 |
-
|
| 2411 |
-
|
| 2412 |
-
def host_ws(self, room_id, owner_session_id):
|
| 2413 |
-
"""The socket that should approve joins: the owner if connected, else
|
| 2414 |
the earliest-connected participant (so approval never deadlocks)."""
|
| 2415 |
peers = self.rooms.get(room_id, {})
|
| 2416 |
if not peers:
|
| 2417 |
return None
|
| 2418 |
-
if owner_session_id:
|
| 2419 |
-
|
| 2420 |
-
|
| 2421 |
-
return ws
|
| 2422 |
-
# fall back to the first admitted participant
|
| 2423 |
-
return next(iter(peers), None)
|
| 2424 |
-
|
| 2425 |
-
def disconnect(self, room_id, ws):
|
| 2426 |
-
peers = self.rooms.get(room_id)
|
| 2427 |
-
if peers and ws in peers:
|
| 2428 |
-
del peers[ws]
|
| 2429 |
-
if not peers:
|
| 2430 |
-
self.rooms.pop(room_id, None)
|
| 2431 |
-
self.remove_pending(room_id, ws)
|
| 2432 |
|
|
|
|
| 2433 |
def presence(self, room_id):
|
| 2434 |
peers = self.rooms.get(room_id, {})
|
| 2435 |
seen, out = set(), []
|
| 2436 |
-
for
|
|
|
|
| 2437 |
if info["session_id"] in seen:
|
| 2438 |
continue
|
| 2439 |
seen.add(info["session_id"])
|
| 2440 |
-
out.append(
|
| 2441 |
return out
|
| 2442 |
|
|
|
|
| 2443 |
async def broadcast(self, room_id, message, exclude=None):
|
| 2444 |
-
for
|
| 2445 |
-
if
|
| 2446 |
continue
|
| 2447 |
try:
|
| 2448 |
-
await
|
| 2449 |
except Exception:
|
| 2450 |
pass
|
| 2451 |
|
| 2452 |
-
async def send_to(self,
|
|
|
|
|
|
|
|
|
|
| 2453 |
try:
|
| 2454 |
-
await
|
| 2455 |
return True
|
| 2456 |
except Exception:
|
| 2457 |
return False
|
|
@@ -2459,124 +2519,175 @@ class Manager:
|
|
| 2459 |
|
| 2460 |
manager = Manager()
|
| 2461 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2462 |
|
| 2463 |
-
|
| 2464 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2465 |
if room_row(room_id) is None:
|
| 2466 |
-
|
| 2467 |
-
|
| 2468 |
-
|
| 2469 |
-
return
|
| 2470 |
|
| 2471 |
-
session_id = (
|
| 2472 |
info = {
|
| 2473 |
"session_id": session_id,
|
| 2474 |
-
"name": sanitize_name(
|
| 2475 |
"participant_id": secrets.token_hex(6),
|
| 2476 |
"color": color_for(session_id),
|
| 2477 |
}
|
| 2478 |
|
| 2479 |
-
|
| 2480 |
-
|
| 2481 |
-
#
|
| 2482 |
-
# un-joinable / approval never deadlocks). Returning sessions that the host
|
| 2483 |
-
# already admitted earlier in this room are also let straight back in.
|
| 2484 |
row = room_row(room_id)
|
| 2485 |
owner_session = row["owner_session_id"] if row else None
|
| 2486 |
is_owner = bool(owner_session) and session_id == owner_session
|
| 2487 |
-
|
| 2488 |
-
await ws.accept()
|
| 2489 |
-
host = manager.host_ws(room_id, owner_session)
|
| 2490 |
needs_approval = (not is_owner) and (host is not None)
|
| 2491 |
|
| 2492 |
if needs_approval:
|
| 2493 |
-
|
| 2494 |
-
|
| 2495 |
-
|
| 2496 |
-
|
| 2497 |
-
# tell the joiner they're waiting
|
| 2498 |
-
await manager.send_to(ws, {"type": "join_pending", "you": {k: info[k] for k in ("session_id", "name", "participant_id", "color")}})
|
| 2499 |
-
# ask the host to admit / decline
|
| 2500 |
-
await manager.send_to(host, {
|
| 2501 |
-
"type": "join_request",
|
| 2502 |
-
"participant": {k: info[k] for k in ("session_id", "name", "participant_id", "color")},
|
| 2503 |
-
})
|
| 2504 |
-
# wait for the host's decision (with a timeout → auto-decline)
|
| 2505 |
try:
|
| 2506 |
-
await asyncio.wait_for(
|
| 2507 |
-
except asyncio.TimeoutError:
|
| 2508 |
-
pass
|
| 2509 |
except Exception:
|
| 2510 |
pass
|
| 2511 |
-
if not
|
| 2512 |
-
manager.remove_pending(room_id,
|
| 2513 |
-
await
|
| 2514 |
-
|
| 2515 |
-
|
| 2516 |
-
|
| 2517 |
-
|
| 2518 |
-
|
| 2519 |
-
|
| 2520 |
else:
|
| 2521 |
-
|
| 2522 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2523 |
|
| 2524 |
-
|
| 2525 |
-
|
| 2526 |
-
|
| 2527 |
-
|
| 2528 |
-
|
| 2529 |
-
|
| 2530 |
-
|
| 2531 |
-
|
| 2532 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2533 |
|
| 2534 |
-
await ws.send_json({"type": "init", "you": info, "participants": manager.presence(room_id)})
|
| 2535 |
-
await manager.broadcast(room_id, {"type": "user_joined", "participant": info}, exclude=ws)
|
| 2536 |
-
await manager.broadcast(room_id, {"type": "presence_update", "participants": manager.presence(room_id)})
|
| 2537 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2538 |
try:
|
| 2539 |
-
|
| 2540 |
-
data = await ws.receive_json()
|
| 2541 |
-
t = data.get("type")
|
| 2542 |
-
if t == "rename":
|
| 2543 |
-
info["name"] = sanitize_name(data.get("name"))
|
| 2544 |
-
with DB_LOCK:
|
| 2545 |
-
_conn.execute(
|
| 2546 |
-
"UPDATE participants SET display_name=?, last_seen=? WHERE participant_id=?",
|
| 2547 |
-
(info["name"], time.time(), info["participant_id"]),
|
| 2548 |
-
)
|
| 2549 |
-
_conn.commit()
|
| 2550 |
-
await manager.broadcast(room_id, {"type": "presence_update", "participants": manager.presence(room_id)})
|
| 2551 |
-
continue
|
| 2552 |
-
# ---- host approves / declines a pending joiner ----
|
| 2553 |
-
if t in ("admit_join", "decline_join"):
|
| 2554 |
-
target_sid = data.get("session_id")
|
| 2555 |
-
pend_ws, pend_info = manager.find_pending(room_id, target_sid)
|
| 2556 |
-
if pend_info is not None:
|
| 2557 |
-
pend_info["_admitted"] = (t == "admit_join")
|
| 2558 |
-
ev = pend_info.get("_decision_event")
|
| 2559 |
-
if ev is not None:
|
| 2560 |
-
ev.set()
|
| 2561 |
-
continue
|
| 2562 |
-
# explicitly known (RELAY_EVENTS) or simply not a server-owned type
|
| 2563 |
-
# (NON_RELAY_EVENTS) — this way the frontend can introduce new
|
| 2564 |
-
# interaction events without the server silently dropping them.
|
| 2565 |
-
if t and t not in NON_RELAY_EVENTS:
|
| 2566 |
-
data["from"] = {k: info[k] for k in ("session_id", "name", "participant_id", "color")}
|
| 2567 |
-
await manager.broadcast(room_id, data, exclude=ws)
|
| 2568 |
-
# cursors + transient interaction pings are too chatty to keep
|
| 2569 |
-
# the room alive on; everything else counts as real activity.
|
| 2570 |
-
if t not in ("cursor_position", "interaction"):
|
| 2571 |
-
touch_room(room_id)
|
| 2572 |
-
except WebSocketDisconnect:
|
| 2573 |
-
pass
|
| 2574 |
except Exception:
|
| 2575 |
-
|
| 2576 |
-
|
| 2577 |
-
|
| 2578 |
-
|
| 2579 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2580 |
|
| 2581 |
|
| 2582 |
# ------------------------------ Assets ------------------------------------- #
|
|
|
|
| 61 |
import time
|
| 62 |
import uuid
|
| 63 |
|
| 64 |
+
from fastapi import HTTPException, Request
|
| 65 |
+
from fastapi.responses import FileResponse, HTMLResponse, Response, StreamingResponse
|
| 66 |
|
| 67 |
from gradio import Server
|
| 68 |
|
|
|
|
| 2333 |
return {"version": new_version, "conflict": False}
|
| 2334 |
|
| 2335 |
|
| 2336 |
+
# --------------------------- Realtime sync (SSE) --------------------------- #
|
| 2337 |
|
| 2338 |
_PALETTE = [
|
| 2339 |
"#7c5cff", "#e9698f", "#2bb673", "#f2994a", "#3aa0ff",
|
|
|
|
| 2378 |
}
|
| 2379 |
|
| 2380 |
|
| 2381 |
+
# NOTE: This transport was a raw WebSocket (/ws/{room_id}). Hugging Face
|
| 2382 |
+
# Spaces' router silently drops custom (non-Gradio) WebSocket UPGRADE requests
|
| 2383 |
+
# at the proxy — the handshake never reaches this process, so every connection
|
| 2384 |
+
# loops in "reconnecting". Plain HTTP routes on this same app work fine, so we
|
| 2385 |
+
# mirror what Gradio itself does on Spaces: a one-way Server-Sent Events stream
|
| 2386 |
+
# for server→client messages, plus ordinary POST for client→server messages.
|
| 2387 |
+
#
|
| 2388 |
+
# Each client is identified by its session_id and holds one open SSE GET. The
|
| 2389 |
+
# Manager keys connections by an asyncio.Queue per client instead of a socket;
|
| 2390 |
+
# "sending" to a client just puts a message on its queue, which the SSE
|
| 2391 |
+
# generator drains to the browser. Presence, join-approval, the relay
|
| 2392 |
+
# allow-list, and the DB writes are all unchanged from the WebSocket version.
|
| 2393 |
+
#
|
| 2394 |
+
# SINGLE-REPLICA ASSUMPTION: a POST and the matching SSE stream must land on the
|
| 2395 |
+
# same process. Your Space runs one replica today, so this holds. If you ever
|
| 2396 |
+
# scale to multiple replicas, move the queues behind Redis pub/sub so a POST on
|
| 2397 |
+
# replica A reaches a stream on replica B.
|
| 2398 |
+
|
| 2399 |
+
|
| 2400 |
+
# The four fields that are safe to broadcast to other participants. `info`
|
| 2401 |
+
# never contains anything else now, but we filter through this everywhere it
|
| 2402 |
+
# crosses the wire so a stray non-JSON value (e.g. an asyncio.Event) can never
|
| 2403 |
+
# crash the SSE stream's json.dumps.
|
| 2404 |
+
_PUBLIC_FIELDS = ("session_id", "name", "participant_id", "color")
|
| 2405 |
+
|
| 2406 |
+
|
| 2407 |
+
def _public(info):
|
| 2408 |
+
"""Project a participant's info down to only the JSON-safe public fields."""
|
| 2409 |
+
return {k: info[k] for k in _PUBLIC_FIELDS}
|
| 2410 |
+
|
| 2411 |
+
|
| 2412 |
+
class _Client:
|
| 2413 |
+
"""One connected participant: their info + the queue feeding their SSE."""
|
| 2414 |
+
def __init__(self, info):
|
| 2415 |
+
self.info = info
|
| 2416 |
+
self.queue: asyncio.Queue = asyncio.Queue()
|
| 2417 |
+
self.alive = True
|
| 2418 |
+
|
| 2419 |
+
async def put(self, message):
|
| 2420 |
+
if self.alive:
|
| 2421 |
+
await self.queue.put(message)
|
| 2422 |
+
|
| 2423 |
+
|
| 2424 |
+
class _PendingJoin:
|
| 2425 |
+
"""A joiner awaiting host approval. Holds the decision Event SEPARATELY from
|
| 2426 |
+
`info` so `info` stays pure-JSON and can be broadcast safely."""
|
| 2427 |
+
def __init__(self, info):
|
| 2428 |
+
self.info = info
|
| 2429 |
+
self.event = asyncio.Event()
|
| 2430 |
+
self.admitted = False
|
| 2431 |
+
|
| 2432 |
+
|
| 2433 |
class Manager:
|
| 2434 |
def __init__(self):
|
| 2435 |
+
self.rooms = {} # room_id -> {session_id: _Client} (admitted)
|
| 2436 |
+
self.pending = {} # room_id -> {session_id: info} (awaiting host)
|
| 2437 |
|
| 2438 |
+
# ---- admitted participants -------------------------------------------- #
|
| 2439 |
+
def add_client(self, room_id, client):
|
| 2440 |
+
self.rooms.setdefault(room_id, {})[client.info["session_id"]] = client
|
| 2441 |
|
| 2442 |
+
def get_client(self, room_id, session_id):
|
| 2443 |
+
return self.rooms.get(room_id, {}).get(session_id)
|
| 2444 |
+
|
| 2445 |
+
def disconnect(self, room_id, session_id):
|
| 2446 |
+
peers = self.rooms.get(room_id)
|
| 2447 |
+
if peers and session_id in peers:
|
| 2448 |
+
peers[session_id].alive = False
|
| 2449 |
+
del peers[session_id]
|
| 2450 |
+
if not peers:
|
| 2451 |
+
self.rooms.pop(room_id, None)
|
| 2452 |
+
self.remove_pending(room_id, session_id)
|
| 2453 |
|
| 2454 |
+
# ---- pending (awaiting host approval) --------------------------------- #
|
| 2455 |
+
def admit(self, room_id, info):
|
| 2456 |
+
"""Promote a pending session to a full participant; returns its _Client."""
|
| 2457 |
+
self.pending.get(room_id, {}).pop(info["session_id"], None)
|
| 2458 |
+
client = _Client(info)
|
| 2459 |
+
self.add_client(room_id, client)
|
| 2460 |
+
return client
|
| 2461 |
|
| 2462 |
+
def add_pending(self, room_id, pending):
|
| 2463 |
+
self.pending.setdefault(room_id, {})[pending.info["session_id"]] = pending
|
| 2464 |
+
|
| 2465 |
+
def remove_pending(self, room_id, session_id):
|
| 2466 |
p = self.pending.get(room_id)
|
| 2467 |
+
if p and session_id in p:
|
| 2468 |
+
del p[session_id]
|
| 2469 |
if not p:
|
| 2470 |
self.pending.pop(room_id, None)
|
| 2471 |
|
| 2472 |
def find_pending(self, room_id, session_id):
|
| 2473 |
+
"""Return the _PendingJoin for a session, or None."""
|
| 2474 |
+
return self.pending.get(room_id, {}).get(session_id)
|
| 2475 |
+
|
| 2476 |
+
# ---- host resolution (who approves joins) ----------------------------- #
|
| 2477 |
+
def host_client(self, room_id, owner_session_id):
|
| 2478 |
+
"""The client that should approve joins: the owner if connected, else
|
|
|
|
|
|
|
| 2479 |
the earliest-connected participant (so approval never deadlocks)."""
|
| 2480 |
peers = self.rooms.get(room_id, {})
|
| 2481 |
if not peers:
|
| 2482 |
return None
|
| 2483 |
+
if owner_session_id and owner_session_id in peers:
|
| 2484 |
+
return peers[owner_session_id]
|
| 2485 |
+
return next(iter(peers.values()), None)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2486 |
|
| 2487 |
+
# ---- presence --------------------------------------------------------- #
|
| 2488 |
def presence(self, room_id):
|
| 2489 |
peers = self.rooms.get(room_id, {})
|
| 2490 |
seen, out = set(), []
|
| 2491 |
+
for client in peers.values():
|
| 2492 |
+
info = client.info
|
| 2493 |
if info["session_id"] in seen:
|
| 2494 |
continue
|
| 2495 |
seen.add(info["session_id"])
|
| 2496 |
+
out.append(_public(info))
|
| 2497 |
return out
|
| 2498 |
|
| 2499 |
+
# ---- delivery --------------------------------------------------------- #
|
| 2500 |
async def broadcast(self, room_id, message, exclude=None):
|
| 2501 |
+
for sid, client in list(self.rooms.get(room_id, {}).items()):
|
| 2502 |
+
if sid == exclude:
|
| 2503 |
continue
|
| 2504 |
try:
|
| 2505 |
+
await client.put(message)
|
| 2506 |
except Exception:
|
| 2507 |
pass
|
| 2508 |
|
| 2509 |
+
async def send_to(self, room_id, session_id, message):
|
| 2510 |
+
client = self.get_client(room_id, session_id)
|
| 2511 |
+
if client is None:
|
| 2512 |
+
return False
|
| 2513 |
try:
|
| 2514 |
+
await client.put(message)
|
| 2515 |
return True
|
| 2516 |
except Exception:
|
| 2517 |
return False
|
|
|
|
| 2519 |
|
| 2520 |
manager = Manager()
|
| 2521 |
|
| 2522 |
+
# Pending joiners don't have a _Client/queue yet (they're not admitted), but
|
| 2523 |
+
# they still need to receive join_pending / join_declined over their own SSE
|
| 2524 |
+
# stream. We give every SSE connection a queue up front, keyed by session_id,
|
| 2525 |
+
# so the stream exists from the moment the browser connects — even while the
|
| 2526 |
+
# session is still waiting for host approval.
|
| 2527 |
+
_sse_queues = {} # (room_id, session_id) -> asyncio.Queue
|
| 2528 |
+
|
| 2529 |
+
|
| 2530 |
+
def _sse_queue(room_id, session_id):
|
| 2531 |
+
key = (room_id, session_id)
|
| 2532 |
+
q = _sse_queues.get(key)
|
| 2533 |
+
if q is None:
|
| 2534 |
+
q = asyncio.Queue()
|
| 2535 |
+
_sse_queues[key] = q
|
| 2536 |
+
return q
|
| 2537 |
+
|
| 2538 |
+
|
| 2539 |
+
async def _relay_incoming(room_id, session_id, data):
|
| 2540 |
+
"""Apply one client→server event (formerly a WS receive_json). Shared by the
|
| 2541 |
+
POST /send endpoint. `data` already has a 'type'."""
|
| 2542 |
+
client = manager.get_client(room_id, session_id)
|
| 2543 |
+
info = client.info if client else None
|
| 2544 |
+
|
| 2545 |
+
t = data.get("type")
|
| 2546 |
+
|
| 2547 |
+
if t == "rename" and info is not None:
|
| 2548 |
+
info["name"] = sanitize_name(data.get("name"))
|
| 2549 |
+
with DB_LOCK:
|
| 2550 |
+
_conn.execute(
|
| 2551 |
+
"UPDATE participants SET display_name=?, last_seen=? WHERE participant_id=?",
|
| 2552 |
+
(info["name"], time.time(), info["participant_id"]),
|
| 2553 |
+
)
|
| 2554 |
+
_conn.commit()
|
| 2555 |
+
await manager.broadcast(room_id, {"type": "presence_update", "participants": manager.presence(room_id)})
|
| 2556 |
+
return
|
| 2557 |
+
|
| 2558 |
+
# ---- host approves / declines a pending joiner ----
|
| 2559 |
+
if t in ("admit_join", "decline_join"):
|
| 2560 |
+
target_sid = data.get("session_id")
|
| 2561 |
+
pend = manager.find_pending(room_id, target_sid)
|
| 2562 |
+
if pend is not None:
|
| 2563 |
+
pend.admitted = (t == "admit_join")
|
| 2564 |
+
pend.event.set()
|
| 2565 |
+
return
|
| 2566 |
+
|
| 2567 |
+
# explicitly known (RELAY_EVENTS) or simply not a server-owned type
|
| 2568 |
+
# (NON_RELAY_EVENTS) — this way the frontend can introduce new interaction
|
| 2569 |
+
# events without the server silently dropping them.
|
| 2570 |
+
if t and t not in NON_RELAY_EVENTS and info is not None:
|
| 2571 |
+
data["from"] = _public(info)
|
| 2572 |
+
await manager.broadcast(room_id, data, exclude=session_id)
|
| 2573 |
+
# cursors + transient interaction pings are too chatty to keep the room
|
| 2574 |
+
# alive on; everything else counts as real activity.
|
| 2575 |
+
if t not in ("cursor_position", "interaction"):
|
| 2576 |
+
touch_room(room_id)
|
| 2577 |
|
| 2578 |
+
|
| 2579 |
+
@app.get("/api/rooms/{room_id}/stream")
|
| 2580 |
+
async def room_stream(room_id: str, request: Request):
|
| 2581 |
+
"""Server-Sent Events stream: server→client messages for one participant.
|
| 2582 |
+
|
| 2583 |
+
Replaces the inbound half of the old WebSocket. The browser opens this with
|
| 2584 |
+
EventSource(...). Query params: session_id, name."""
|
| 2585 |
if room_row(room_id) is None:
|
| 2586 |
+
# mimic the old 4404: emit a single error event then end the stream.
|
| 2587 |
+
async def _gone():
|
| 2588 |
+
yield 'data: {"type":"error","message":"Room not found or expired."}\n\n'
|
| 2589 |
+
return StreamingResponse(_gone(), media_type="text/event-stream")
|
| 2590 |
|
| 2591 |
+
session_id = (request.query_params.get("session_id") or secrets.token_hex(8))[:64]
|
| 2592 |
info = {
|
| 2593 |
"session_id": session_id,
|
| 2594 |
+
"name": sanitize_name(request.query_params.get("name")),
|
| 2595 |
"participant_id": secrets.token_hex(6),
|
| 2596 |
"color": color_for(session_id),
|
| 2597 |
}
|
| 2598 |
|
| 2599 |
+
q = _sse_queue(room_id, session_id)
|
| 2600 |
+
|
| 2601 |
+
# ---- join approval (same policy as the WS version) ----
|
|
|
|
|
|
|
| 2602 |
row = room_row(room_id)
|
| 2603 |
owner_session = row["owner_session_id"] if row else None
|
| 2604 |
is_owner = bool(owner_session) and session_id == owner_session
|
| 2605 |
+
host = manager.host_client(room_id, owner_session)
|
|
|
|
|
|
|
| 2606 |
needs_approval = (not is_owner) and (host is not None)
|
| 2607 |
|
| 2608 |
if needs_approval:
|
| 2609 |
+
pend = _PendingJoin(info)
|
| 2610 |
+
manager.add_pending(room_id, pend)
|
| 2611 |
+
await q.put({"type": "join_pending", "you": _public(info)})
|
| 2612 |
+
await host.put({"type": "join_request", "participant": _public(info)})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2613 |
try:
|
| 2614 |
+
await asyncio.wait_for(pend.event.wait(), timeout=120)
|
|
|
|
|
|
|
| 2615 |
except Exception:
|
| 2616 |
pass
|
| 2617 |
+
if not pend.admitted:
|
| 2618 |
+
manager.remove_pending(room_id, session_id)
|
| 2619 |
+
await q.put({"type": "join_declined"})
|
| 2620 |
+
await q.put({"__close__": True})
|
| 2621 |
+
# fall through to the generator, which will flush join_declined then end
|
| 2622 |
+
client = None
|
| 2623 |
+
else:
|
| 2624 |
+
client = manager.admit(room_id, info)
|
| 2625 |
+
client.queue = q # reuse the queue the SSE stream is already draining
|
| 2626 |
else:
|
| 2627 |
+
client = _Client(info)
|
| 2628 |
+
client.queue = q
|
| 2629 |
+
manager.add_client(room_id, client)
|
| 2630 |
+
|
| 2631 |
+
if client is not None:
|
| 2632 |
+
now = time.time()
|
| 2633 |
+
with DB_LOCK:
|
| 2634 |
+
_conn.execute(
|
| 2635 |
+
"INSERT OR REPLACE INTO participants (participant_id, room_id, "
|
| 2636 |
+
"session_id, display_name, joined_at, last_seen) VALUES (?,?,?,?,?,?)",
|
| 2637 |
+
(info["participant_id"], room_id, session_id, info["name"], now, now),
|
| 2638 |
+
)
|
| 2639 |
+
_conn.commit()
|
| 2640 |
+
touch_room(room_id)
|
| 2641 |
+
await q.put({"type": "init", "you": _public(info), "participants": manager.presence(room_id)})
|
| 2642 |
+
await manager.broadcast(room_id, {"type": "user_joined", "participant": _public(info)}, exclude=session_id)
|
| 2643 |
+
await manager.broadcast(room_id, {"type": "presence_update", "participants": manager.presence(room_id)})
|
| 2644 |
|
| 2645 |
+
async def event_gen():
|
| 2646 |
+
try:
|
| 2647 |
+
while True:
|
| 2648 |
+
# heartbeat keeps the connection alive through idle proxies
|
| 2649 |
+
try:
|
| 2650 |
+
msg = await asyncio.wait_for(q.get(), timeout=20)
|
| 2651 |
+
except asyncio.TimeoutError:
|
| 2652 |
+
yield ": keep-alive\n\n"
|
| 2653 |
+
continue
|
| 2654 |
+
if isinstance(msg, dict) and msg.get("__close__"):
|
| 2655 |
+
break
|
| 2656 |
+
yield f"data: {json.dumps(msg, separators=(',', ':'))}\n\n"
|
| 2657 |
+
except asyncio.CancelledError:
|
| 2658 |
+
pass
|
| 2659 |
+
finally:
|
| 2660 |
+
_sse_queues.pop((room_id, session_id), None)
|
| 2661 |
+
if manager.get_client(room_id, session_id) is not None:
|
| 2662 |
+
manager.disconnect(room_id, session_id)
|
| 2663 |
+
await manager.broadcast(room_id, {"type": "user_left", "participant": _public(info)})
|
| 2664 |
+
await manager.broadcast(room_id, {"type": "presence_update", "participants": manager.presence(room_id)})
|
| 2665 |
+
|
| 2666 |
+
return StreamingResponse(event_gen(), media_type="text/event-stream", headers={
|
| 2667 |
+
"Cache-Control": "no-cache",
|
| 2668 |
+
"X-Accel-Buffering": "no", # disable proxy buffering so events flush live
|
| 2669 |
+
"Connection": "keep-alive",
|
| 2670 |
+
})
|
| 2671 |
|
|
|
|
|
|
|
|
|
|
| 2672 |
|
| 2673 |
+
@app.post("/api/rooms/{room_id}/send")
|
| 2674 |
+
async def room_send(room_id: str, request: Request):
|
| 2675 |
+
"""Client→server messages: one event the client used to send over the
|
| 2676 |
+
socket (object_updated, chat_message, cursor_position, admit_join, ...)."""
|
| 2677 |
+
if room_row(room_id) is None:
|
| 2678 |
+
raise HTTPException(status_code=404, detail="Room not found or expired.")
|
| 2679 |
try:
|
| 2680 |
+
body = await request.json()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2681 |
except Exception:
|
| 2682 |
+
raise HTTPException(status_code=400, detail="Invalid JSON.")
|
| 2683 |
+
session_id = (body or {}).get("session_id")
|
| 2684 |
+
if not session_id:
|
| 2685 |
+
raise HTTPException(status_code=400, detail="Missing session_id.")
|
| 2686 |
+
data = (body or {}).get("event") or {}
|
| 2687 |
+
if not isinstance(data, dict) or not data.get("type"):
|
| 2688 |
+
raise HTTPException(status_code=400, detail="Missing event.type.")
|
| 2689 |
+
await _relay_incoming(room_id, session_id, data)
|
| 2690 |
+
return {"ok": True}
|
| 2691 |
|
| 2692 |
|
| 2693 |
# ------------------------------ Assets ------------------------------------- #
|
index.html
CHANGED
|
@@ -361,12 +361,16 @@
|
|
| 361 |
</script>
|
| 362 |
|
| 363 |
<!-- ----------------------------------------------------------------- -->
|
| 364 |
-
<!-- Collaboration transport (plain JS):
|
| 365 |
-
<!--
|
| 366 |
<!-- ----------------------------------------------------------------- -->
|
| 367 |
<script>
|
| 368 |
window.Collab = (function () {
|
| 369 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 370 |
const listeners = {};
|
| 371 |
|
| 372 |
function roomId() {
|
|
@@ -385,27 +389,38 @@
|
|
| 385 |
}
|
| 386 |
function emit(type, d) { (listeners[type] || []).forEach((fn) => { try { fn(d); } catch (e) {} }); }
|
| 387 |
function send(type, payload) {
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 393 |
}
|
| 394 |
function connect() {
|
| 395 |
const id = roomId();
|
| 396 |
if (!id) { emit("status", { connected: false, solo: true }); return; }
|
| 397 |
-
|
| 398 |
-
const url = `
|
| 399 |
-
try {
|
| 400 |
-
|
| 401 |
-
|
| 402 |
let m; try { m = JSON.parse(e.data); } catch (_) { return; }
|
| 403 |
if (m.type === "init") { me = m.you; participants = m.participants || []; emit("me", me); emit("presence", participants); }
|
| 404 |
else if (m.type === "presence_update") { participants = m.participants || []; emit("presence", participants); }
|
| 405 |
emit(m.type, m); emit("any", m);
|
| 406 |
};
|
| 407 |
-
|
| 408 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 409 |
}
|
| 410 |
function rename(n) { localStorage.setItem("sozai_name", n); if (me) me.name = n; send("rename", { name: n }); }
|
| 411 |
|
|
|
|
| 361 |
</script>
|
| 362 |
|
| 363 |
<!-- ----------------------------------------------------------------- -->
|
| 364 |
+
<!-- Collaboration transport (plain JS): SSE (inbound) + POST (outbound) -->
|
| 365 |
+
<!-- over /api/rooms/{id}/stream and /send. No-ops cleanly in solo. -->
|
| 366 |
<!-- ----------------------------------------------------------------- -->
|
| 367 |
<script>
|
| 368 |
window.Collab = (function () {
|
| 369 |
+
// Transport: Server-Sent Events (inbound) + POST (outbound). HF Spaces'
|
| 370 |
+
// proxy drops custom WebSocket upgrades, so we use the same SSE+POST
|
| 371 |
+
// channel Gradio itself uses on Spaces. EventSource auto-reconnects on
|
| 372 |
+
// its own, so there's no manual reconnect loop here.
|
| 373 |
+
let es = null, me = null, participants = [];
|
| 374 |
const listeners = {};
|
| 375 |
|
| 376 |
function roomId() {
|
|
|
|
| 389 |
}
|
| 390 |
function emit(type, d) { (listeners[type] || []).forEach((fn) => { try { fn(d); } catch (e) {} }); }
|
| 391 |
function send(type, payload) {
|
| 392 |
+
const id = roomId();
|
| 393 |
+
if (!id) return;
|
| 394 |
+
const event = Object.assign({ type }, payload || {});
|
| 395 |
+
try {
|
| 396 |
+
fetch(`/api/rooms/${encodeURIComponent(id)}/send`, {
|
| 397 |
+
method: "POST",
|
| 398 |
+
headers: { "Content-Type": "application/json" },
|
| 399 |
+
// keepalive lets the final send (e.g. on tab close) still go out
|
| 400 |
+
keepalive: type === "rename",
|
| 401 |
+
body: JSON.stringify({ session_id: sid(), event }),
|
| 402 |
+
}).catch(() => {});
|
| 403 |
+
} catch (e) {}
|
| 404 |
}
|
| 405 |
function connect() {
|
| 406 |
const id = roomId();
|
| 407 |
if (!id) { emit("status", { connected: false, solo: true }); return; }
|
| 408 |
+
try { if (es) es.close(); } catch (_) {}
|
| 409 |
+
const url = `/api/rooms/${encodeURIComponent(id)}/stream?session_id=${encodeURIComponent(sid())}&name=${encodeURIComponent(name() || "Guest")}`;
|
| 410 |
+
try { es = new EventSource(url); } catch (e) { return; }
|
| 411 |
+
es.onopen = () => emit("status", { connected: true, solo: false });
|
| 412 |
+
es.onmessage = (e) => {
|
| 413 |
let m; try { m = JSON.parse(e.data); } catch (_) { return; }
|
| 414 |
if (m.type === "init") { me = m.you; participants = m.participants || []; emit("me", me); emit("presence", participants); }
|
| 415 |
else if (m.type === "presence_update") { participants = m.participants || []; emit("presence", participants); }
|
| 416 |
emit(m.type, m); emit("any", m);
|
| 417 |
};
|
| 418 |
+
// EventSource fires onerror on transient drops and reconnects itself;
|
| 419 |
+
// we just surface the status. A closed (readyState 2) stream means the
|
| 420 |
+
// server ended it (e.g. join_declined) — don't fight it.
|
| 421 |
+
es.onerror = () => {
|
| 422 |
+
emit("status", { connected: false, solo: false });
|
| 423 |
+
};
|
| 424 |
}
|
| 425 |
function rename(n) { localStorage.setItem("sozai_name", n); if (me) me.name = n; send("rename", { name: n }); }
|
| 426 |
|
packages.txt
CHANGED
|
@@ -1 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
libheif-dev
|
|
|
|
| 1 |
+
ffmpeg
|
| 2 |
+
libsndfile1
|
| 3 |
+
sox
|
| 4 |
+
libsox-fmt-all
|
| 5 |
libheif-dev
|
requirements.txt
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
gradio
|
| 2 |
-
|
| 3 |
transformers
|
| 4 |
# Flux2KleinPipeline (img2img watercolour develop) is not in a released diffusers
|
| 5 |
# yet — it must come from git main. See backend/hf/3_infer_img2img.py.
|
|
|
|
| 1 |
gradio
|
| 2 |
+
websockets>=12.0
|
| 3 |
transformers
|
| 4 |
# Flux2KleinPipeline (img2img watercolour develop) is not in a released diffusers
|
| 5 |
# yet — it must come from git main. See backend/hf/3_infer_img2img.py.
|