fix(performance): use simple height estimation instead of PIL rendering

- Replace estimate_block_height (PIL-based) with estimate_simple_height (word wrap)
- Update viewport filter tests to match new height-based filtering (~4 items vs 24)
- Fix CI task duplication in mise.toml (remove redundant depends)

Closes #38
Closes #36
This commit is contained in:
2026-03-18 22:33:36 -07:00
parent abe49ba7d7
commit c57617bb3d
26 changed files with 3938 additions and 1956 deletions

View File

@@ -101,7 +101,7 @@ class PygameDisplay:
import os
os.environ["SDL_VIDEODRIVER"] = "x11"
os.environ["SDL_VIDEODRIVER"] = "dummy"
try:
import pygame

View File

@@ -1,6 +1,11 @@
"""
WebSocket display backend - broadcasts frame buffer to connected web clients.
Supports streaming protocols:
- Full frame (JSON) - default for compatibility
- Binary streaming - efficient binary protocol
- Diff streaming - only sends changed lines
TODO: Transform to a true streaming backend with:
- Proper WebSocket message streaming (currently sends full buffer each frame)
- Connection pooling and backpressure handling
@@ -12,9 +17,28 @@ Current implementation: Simple broadcast of text frames to all connected clients
"""
import asyncio
import base64
import json
import threading
import time
from enum import IntFlag
from engine.display.streaming import (
MessageType,
compress_frame,
compute_diff,
encode_binary_message,
encode_diff_message,
)
class StreamingMode(IntFlag):
"""Streaming modes for WebSocket display."""
JSON = 0x01 # Full JSON frames (default, compatible)
BINARY = 0x02 # Binary compression
DIFF = 0x04 # Differential updates
try:
import websockets
@@ -43,6 +67,7 @@ class WebSocketDisplay:
host: str = "0.0.0.0",
port: int = 8765,
http_port: int = 8766,
streaming_mode: StreamingMode = StreamingMode.JSON,
):
self.host = host
self.port = port
@@ -58,7 +83,15 @@ class WebSocketDisplay:
self._max_clients = 10
self._client_connected_callback = None
self._client_disconnected_callback = None
self._command_callback = None
self._controller = None # Reference to UI panel or pipeline controller
self._frame_delay = 0.0
self._httpd = None # HTTP server instance
# Streaming configuration
self._streaming_mode = streaming_mode
self._last_buffer: list[str] = []
self._client_capabilities: dict = {} # Track client capabilities
try:
import websockets as _ws
@@ -87,7 +120,7 @@ class WebSocketDisplay:
self.start_http_server()
def show(self, buffer: list[str], border: bool = False) -> None:
"""Broadcast buffer to all connected clients."""
"""Broadcast buffer to all connected clients using streaming protocol."""
t0 = time.perf_counter()
# Get metrics for border display
@@ -108,33 +141,82 @@ class WebSocketDisplay:
buffer = render_border(buffer, self.width, self.height, fps, frame_time)
if self._clients:
frame_data = {
"type": "frame",
"width": self.width,
"height": self.height,
"lines": buffer,
}
message = json.dumps(frame_data)
if not self._clients:
self._last_buffer = buffer
return
disconnected = set()
for client in list(self._clients):
try:
asyncio.run(client.send(message))
except Exception:
disconnected.add(client)
# Send to each client based on their capabilities
disconnected = set()
for client in list(self._clients):
try:
client_id = id(client)
client_mode = self._client_capabilities.get(
client_id, StreamingMode.JSON
)
for client in disconnected:
self._clients.discard(client)
if self._client_disconnected_callback:
self._client_disconnected_callback(client)
if client_mode & StreamingMode.DIFF:
self._send_diff_frame(client, buffer)
elif client_mode & StreamingMode.BINARY:
self._send_binary_frame(client, buffer)
else:
self._send_json_frame(client, buffer)
except Exception:
disconnected.add(client)
for client in disconnected:
self._clients.discard(client)
if self._client_disconnected_callback:
self._client_disconnected_callback(client)
self._last_buffer = buffer
elapsed_ms = (time.perf_counter() - t0) * 1000
monitor = get_monitor()
if monitor:
chars_in = sum(len(line) for line in buffer)
monitor.record_effect("websocket_display", elapsed_ms, chars_in, chars_in)
def _send_json_frame(self, client, buffer: list[str]) -> None:
"""Send frame as JSON."""
frame_data = {
"type": "frame",
"width": self.width,
"height": self.height,
"lines": buffer,
}
message = json.dumps(frame_data)
asyncio.run(client.send(message))
def _send_binary_frame(self, client, buffer: list[str]) -> None:
"""Send frame as compressed binary."""
compressed = compress_frame(buffer)
message = encode_binary_message(
MessageType.FULL_FRAME, self.width, self.height, compressed
)
encoded = base64.b64encode(message).decode("utf-8")
asyncio.run(client.send(encoded))
def _send_diff_frame(self, client, buffer: list[str]) -> None:
"""Send frame as diff."""
diff = compute_diff(self._last_buffer, buffer)
if not diff.changed_lines:
return
diff_payload = encode_diff_message(diff)
message = encode_binary_message(
MessageType.DIFF_FRAME, self.width, self.height, diff_payload
)
encoded = base64.b64encode(message).decode("utf-8")
asyncio.run(client.send(encoded))
def set_streaming_mode(self, mode: StreamingMode) -> None:
"""Set the default streaming mode for new clients."""
self._streaming_mode = mode
def get_streaming_mode(self) -> StreamingMode:
"""Get the current streaming mode."""
return self._streaming_mode
def clear(self) -> None:
"""Broadcast clear command to all clients."""
if self._clients:
@@ -165,9 +247,21 @@ class WebSocketDisplay:
async for message in websocket:
try:
data = json.loads(message)
if data.get("type") == "resize":
msg_type = data.get("type")
if msg_type == "resize":
self.width = data.get("width", 80)
self.height = data.get("height", 24)
elif msg_type == "command" and self._command_callback:
# Forward commands to the pipeline controller
command = data.get("command", {})
self._command_callback(command)
elif msg_type == "state_request":
# Send current state snapshot
state = self._get_state_snapshot()
if state:
response = {"type": "state", "state": state}
await websocket.send(json.dumps(response))
except json.JSONDecodeError:
pass
except Exception:
@@ -179,6 +273,8 @@ class WebSocketDisplay:
async def _run_websocket_server(self):
"""Run the WebSocket server."""
if not websockets:
return
async with websockets.serve(self._websocket_handler, self.host, self.port):
while self._server_running:
await asyncio.sleep(0.1)
@@ -188,9 +284,23 @@ class WebSocketDisplay:
import os
from http.server import HTTPServer, SimpleHTTPRequestHandler
client_dir = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "client"
)
# Find the project root by locating 'engine' directory in the path
websocket_file = os.path.abspath(__file__)
parts = websocket_file.split(os.sep)
if "engine" in parts:
engine_idx = parts.index("engine")
project_root = os.sep.join(parts[:engine_idx])
client_dir = os.path.join(project_root, "client")
else:
# Fallback: go up 4 levels from websocket.py
# websocket.py: .../engine/display/backends/websocket.py
# We need: .../client
client_dir = os.path.join(
os.path.dirname(
os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
),
"client",
)
class Handler(SimpleHTTPRequestHandler):
def __init__(self, *args, **kwargs):
@@ -200,8 +310,10 @@ class WebSocketDisplay:
pass
httpd = HTTPServer((self.host, self.http_port), Handler)
while self._http_running:
httpd.handle_request()
# Store reference for shutdown
self._httpd = httpd
# Serve requests continuously
httpd.serve_forever()
def _run_async(self, coro):
"""Run coroutine in background."""
@@ -246,6 +358,8 @@ class WebSocketDisplay:
def stop_http_server(self):
"""Stop the HTTP server."""
self._http_running = False
if hasattr(self, "_httpd") and self._httpd:
self._httpd.shutdown()
self._http_thread = None
def client_count(self) -> int:
@@ -276,6 +390,71 @@ class WebSocketDisplay:
"""Set callback for client disconnections."""
self._client_disconnected_callback = callback
def set_command_callback(self, callback) -> None:
"""Set callback for incoming command messages from clients."""
self._command_callback = callback
def set_controller(self, controller) -> None:
"""Set controller (UI panel or pipeline) for state queries and command execution."""
self._controller = controller
def broadcast_state(self, state: dict) -> None:
"""Broadcast state update to all connected clients.
Args:
state: Dictionary containing state data to send to clients
"""
if not self._clients:
return
message = json.dumps({"type": "state", "state": state})
disconnected = set()
for client in list(self._clients):
try:
asyncio.run(client.send(message))
except Exception:
disconnected.add(client)
for client in disconnected:
self._clients.discard(client)
if self._client_disconnected_callback:
self._client_disconnected_callback(client)
def _get_state_snapshot(self) -> dict | None:
"""Get current state snapshot from controller."""
if not self._controller:
return None
try:
# Expect controller to have methods we need
state = {}
# Get stages info if UIPanel
if hasattr(self._controller, "stages"):
state["stages"] = {
name: {
"enabled": ctrl.enabled,
"params": ctrl.params,
"selected": ctrl.selected,
}
for name, ctrl in self._controller.stages.items()
}
# Get current preset
if hasattr(self._controller, "_current_preset"):
state["preset"] = self._controller._current_preset
if hasattr(self._controller, "_presets"):
state["presets"] = self._controller._presets
# Get selected stage
if hasattr(self._controller, "selected_stage"):
state["selected_stage"] = self._controller.selected_stage
return state
except Exception:
return None
def get_dimensions(self) -> tuple[int, int]:
"""Get current dimensions.