Files
automatic/modules/api/preview.py
T
2026-06-16 20:20:18 +02:00

132 lines
4.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import io
import asyncio
from fastapi import WebSocket, WebSocketDisconnect
from modules.logger import log
class PreviewManager:
"""Manages WebSocket connections for step-based live preview streaming.
Each denoising step, diffusers_callback calls push_step() from the
generation thread. Image encoding happens there (nonblocking for
TAESD, the default preview method) and the binary JPEG + JSON progress
are dispatched to the event loop via run_coroutine_threadsafe().
"""
def __init__(self):
self.connections: set[WebSocket] = set()
self._client_hidden: bool = False
self._loop: asyncio.AbstractEventLoop | None = None
# ------------------------------------------------------------------
# Lifecycle (called from the event-loop thread)
# ------------------------------------------------------------------
async def connect(self, ws: WebSocket):
await ws.accept()
self.connections.add(ws)
if self._loop is None:
self._loop = asyncio.get_event_loop()
log.debug(f'Preview WS connect: client={ws.client.host} total={len(self.connections)}')
async def disconnect(self, ws: WebSocket):
self.connections.discard(ws)
log.debug(f'Preview WS disconnect: client={ws.client.host} total={len(self.connections)}')
async def handle_message(self, data: dict):
"""Receive visibility-change messages from the client."""
if data.get('type') == 'visibility':
self._client_hidden = not data.get('visible', True)
# ------------------------------------------------------------------
# Push from generation thread (threadsafe)
# ------------------------------------------------------------------
def push_step(self, step: int, steps: int, state):
"""Called from the generation thread inside diffusers_callback."""
if not self.connections or self._client_hidden or self._loop is None:
return
# --- lightweight metadata (no GPU ops) ---
progress = round(step / steps, 2) if steps > 0 else 0
data = {
"active": True,
"step": step,
"steps": steps,
"progress": progress,
"job": state.job,
"paused": state.paused,
}
# --- encode preview image ---
img_bytes = None
try:
if state.set_current_image() and state.current_image is not None:
buf = io.BytesIO()
state.current_image.save(buf, format='jpeg', quality=60)
img_bytes = buf.getvalue()
except Exception:
pass
data["has_image"] = img_bytes is not None
# --- dispatch to event loop ---
asyncio.run_coroutine_threadsafe(
self._broadcast(data, img_bytes),
self._loop
)
def push_complete(self):
"""Called when a generation job finishes."""
if not self.connections or self._loop is None:
return
data = {
"active": False,
"step": 0,
"steps": 0,
"progress": 1.0,
"job": "",
"paused": False,
"completed": True,
"has_image": False,
}
asyncio.run_coroutine_threadsafe(
self._broadcast(data, None),
self._loop
)
# ------------------------------------------------------------------
# Async broadcast (runs on event loop)
# ------------------------------------------------------------------
async def _broadcast(self, data: dict, img_bytes: bytes | None):
dead: set[WebSocket] = set()
for ws in self.connections:
try:
await ws.send_json(data)
if img_bytes:
await ws.send_bytes(img_bytes)
except Exception:
dead.add(ws)
self.connections -= dead
preview_manager = PreviewManager()
async def ws_preview(websocket: WebSocket):
await preview_manager.connect(websocket)
try:
while True:
raw = await websocket.receive_text()
try:
import json
msg = json.loads(raw)
await preview_manager.handle_message(msg)
except Exception:
pass
except WebSocketDisconnect:
pass
finally:
await preview_manager.disconnect(websocket)