Shereen Lee commited on
Commit
b685c13
·
1 Parent(s): bb628b8

merge: SSE refactor + ZeroGPU img2img watercolour develop

Browse files
Files changed (4) hide show
  1. app.py +251 -140
  2. index.html +30 -15
  3. packages.txt +4 -0
  4. 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, WebSocket, WebSocketDisconnect
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
- # --------------------------- WebSocket sync -------------------------------- #
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 -> {ws: info} (admitted participants)
2384
- self.pending = {} # room_id -> {ws: info} (awaiting host approval)
2385
 
2386
- async def connect(self, room_id, ws, info):
2387
- await ws.accept()
2388
- self.rooms.setdefault(room_id, {})[ws] = info
2389
 
2390
- def admit(self, room_id, ws, info):
2391
- """Promote a pending socket to a full participant."""
2392
- self.pending.get(room_id, {}).pop(ws, None)
2393
- self.rooms.setdefault(room_id, {})[ws] = info
 
 
 
 
 
 
 
2394
 
2395
- def add_pending(self, room_id, ws, info):
2396
- self.pending.setdefault(room_id, {})[ws] = info
 
 
 
 
 
2397
 
2398
- def remove_pending(self, room_id, ws):
 
 
 
2399
  p = self.pending.get(room_id)
2400
- if p and ws in p:
2401
- del p[ws]
2402
  if not p:
2403
  self.pending.pop(room_id, None)
2404
 
2405
  def find_pending(self, room_id, session_id):
2406
- """Return (ws, info) for a pending session_id, or (None, None)."""
2407
- for ws, info in list(self.pending.get(room_id, {}).items()):
2408
- if info["session_id"] == session_id:
2409
- return ws, info
2410
- return None, None
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
- for ws, info in peers.items():
2420
- if info["session_id"] == owner_session_id:
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 info in peers.values():
 
2437
  if info["session_id"] in seen:
2438
  continue
2439
  seen.add(info["session_id"])
2440
- out.append({k: info[k] for k in ("session_id", "name", "participant_id", "color")})
2441
  return out
2442
 
 
2443
  async def broadcast(self, room_id, message, exclude=None):
2444
- for ws in list(self.rooms.get(room_id, {})):
2445
- if ws is exclude:
2446
  continue
2447
  try:
2448
- await ws.send_json(message)
2449
  except Exception:
2450
  pass
2451
 
2452
- async def send_to(self, ws, message):
 
 
 
2453
  try:
2454
- await ws.send_json(message)
2455
  return True
2456
  except Exception:
2457
  return False
@@ -2459,124 +2519,175 @@ class Manager:
2459
 
2460
  manager = Manager()
2461
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2462
 
2463
- @app.websocket("/ws/{room_id}")
2464
- async def ws_room(ws: WebSocket, room_id: str):
 
 
 
 
 
2465
  if room_row(room_id) is None:
2466
- await ws.accept()
2467
- await ws.send_json({"type": "error", "message": "Room not found or expired."})
2468
- await ws.close(code=4404)
2469
- return
2470
 
2471
- session_id = (ws.query_params.get("session_id") or secrets.token_hex(8))[:64]
2472
  info = {
2473
  "session_id": session_id,
2474
- "name": sanitize_name(ws.query_params.get("name")),
2475
  "participant_id": secrets.token_hex(6),
2476
  "color": color_for(session_id),
2477
  }
2478
 
2479
- # ---- join approval ----
2480
- # The owner is auto-admitted. Anyone else needs the host to admit them.
2481
- # If no host is currently connected, we auto-admit (so a room is never
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
- decision = asyncio.Event()
2494
- info["_decision_event"] = decision
2495
- info["_admitted"] = False
2496
- manager.add_pending(room_id, ws, info)
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(decision.wait(), timeout=120)
2507
- except asyncio.TimeoutError:
2508
- pass
2509
  except Exception:
2510
  pass
2511
- if not info.get("_admitted"):
2512
- manager.remove_pending(room_id, ws)
2513
- await manager.send_to(ws, {"type": "join_declined"})
2514
- try:
2515
- await ws.close(code=4403)
2516
- except Exception:
2517
- pass
2518
- return
2519
- manager.admit(room_id, ws, info)
2520
  else:
2521
- # owner, or no host present → straight in
2522
- manager.rooms.setdefault(room_id, {})[ws] = info
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2523
 
2524
- now = time.time()
2525
- with DB_LOCK:
2526
- _conn.execute(
2527
- "INSERT OR REPLACE INTO participants (participant_id, room_id, "
2528
- "session_id, display_name, joined_at, last_seen) VALUES (?,?,?,?,?,?)",
2529
- (info["participant_id"], room_id, session_id, info["name"], now, now),
2530
- )
2531
- _conn.commit()
2532
- touch_room(room_id)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- while True:
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
- pass
2576
- finally:
2577
- manager.disconnect(room_id, ws)
2578
- await manager.broadcast(room_id, {"type": "user_left", "participant": info})
2579
- await manager.broadcast(room_id, {"type": "presence_update", "participants": manager.presence(room_id)})
 
 
 
 
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): a thin WebSocket pub/sub over -->
365
- <!-- the gradio.Server /ws/{room_id} endpoint. No-ops cleanly in solo. -->
366
  <!-- ----------------------------------------------------------------- -->
367
  <script>
368
  window.Collab = (function () {
369
- let ws = null, me = null, participants = [], reconnectT = null;
 
 
 
 
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
- try { if (ws && ws.readyState === 1) ws.send(JSON.stringify(Object.assign({ type }, payload || {}))); } catch (e) {}
389
- }
390
- function scheduleReconnect() {
391
- if (reconnectT) return;
392
- reconnectT = setTimeout(() => { reconnectT = null; if (roomId()) connect(); }, 1500);
 
 
 
 
 
 
 
393
  }
394
  function connect() {
395
  const id = roomId();
396
  if (!id) { emit("status", { connected: false, solo: true }); return; }
397
- const proto = location.protocol === "https:" ? "wss" : "ws";
398
- const url = `${proto}://${location.host}/ws/${encodeURIComponent(id)}?session_id=${encodeURIComponent(sid())}&name=${encodeURIComponent(name() || "Guest")}`;
399
- try { ws = new WebSocket(url); } catch (e) { scheduleReconnect(); return; }
400
- ws.onopen = () => emit("status", { connected: true, solo: false });
401
- ws.onmessage = (e) => {
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
- ws.onclose = () => { emit("status", { connected: false, solo: false }); scheduleReconnect(); };
408
- ws.onerror = () => { try { ws.close(); } catch (_) {} };
 
 
 
 
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
- uvicorn[standard]
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.