mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-02 01:08:05 +00:00
feat(sync): multi-display wireless sync — extend scrolling across two LED matrices (#330)
* feat(sync): multi-display wireless sync — extend scrolling across two LED matrices Adds a leader/follower sync system that extends Vegas scroll mode content continuously across two physically adjacent LED matrix units over WiFi. Architecture: - Leader broadcasts scroll position via UDP at ~90fps; follower renders the offset slice of the same image at 60fps using dead reckoning to absorb UDP jitter (smooth, stutter-free motion) - At each cycle transition the leader sends the composed scroll image via TCP (PNG-compressed ~15–40KB) so both displays render pixel-identical content regardless of plugin data timing differences - Auto-discovery via UDP subnet broadcast — no IP configuration required - Heartbeat watchdog (6s timeout) falls back to standalone if peer goes offline Key files: - src/common/sync_manager.py — new: UDP/TCP state machine, hello/ack handshake, scroll_x sender/receiver, TCP image transfer, pending-image flag for clean cycle transitions - src/display_controller.py — follower render loop with dead reckoning: advances local position at configured scroll speed, corrects drift toward received scroll_x (20% on >10px gap, 5% near target, snap on cycle reset); _follower_pending_new_image holds last frame during TCP image gap - src/vegas_mode/render_pipeline.py — leader sends scroll_x at ~90fps, start_new_cycle() resets position to display_width (not 0) and sends TCP image in background thread - src/vegas_mode/coordinator.py — set_sync_manager() / set_update_callback() wiring; defers hot-swap recompose while sync is active - web_interface/blueprints/api_v3.py — sync config save endpoint, GET /api/v3/sync/status for live status polling - web_interface/templates/v3/partials/display.html — Multi-Display Sync section: role selector (Standalone/Leader/Follower), position (Left/Right of leader, follower only), UDP port, live status indicator - config/config.template.json — sync block: role, port, follower_position Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(sync): address PR review findings - sync_manager: replace Optional[callable] with proper Callable types from typing; tighten set_on_new_cycle/set_on_scroll_image/set_on_follower_connected signatures to match their actual callback signatures - sync_manager: log a one-shot warning when send_frame produces a packet exceeding the 65000-byte UDP cap instead of silently dropping it - display_controller: correct stale comment in _send_follower_frame (was "30fps / PNG encode/decode"; actual behavior is ~90fps raw RGB) - display.html: guard setInterval with window.syncStatusInterval to prevent duplicate pollers if the script runs more than once - display.html: replace innerHTML with DOM node creation + textContent for status icon/text to avoid inserting API-derived values via innerHTML Skip: time.time() → monotonic and self.config staleness are pre-existing issues not introduced by this PR. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(sync): address second round of PR review findings - sync_manager: guard TCP image receive against OOM — validate length against 10 MB cap before allocating; log and close on invalid length - display_controller: _follower_gated_update now allows update_display() through when the leader is offline (is_follower_active() == False) so the display recovers normally when falling back to standalone mode - coordinator: normalize a standalone SyncManager to None in set_sync_manager() so the render pipeline never treats a no-op manager as an active one - coordinator: derive _UPDATE_TICK_FRAMES from target_fps * 4 instead of the hardcoded 500 so the ~4s cadence holds at any configured FPS - render_pipeline: replace bare except/pass on blank-frame push with logger.exception() so failures are visible in logs Skip: config.template.json comments — JSON does not support inline comments. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(sync): address third round of PR review findings - sync_manager: use 'with socket.socket(...)' in send_scroll_image so the TCP socket is always closed even if connect/sendall raises - sync_manager: add _scroll_image_lock to serialize all reads/writes to _on_scroll_image and _pending_scroll_image between _image_server_loop and set_on_scroll_image, eliminating the lost-delivery race; callback is invoked outside the lock to avoid holding it during user code - sync_manager: validate scroll image dimensions (max 100000×256) and catch DecompressionBombError before img.load() in _image_server_loop - sync_manager: log socket close exceptions at debug level in stop() instead of silently passing - sync_manager: replace hardcoded /tmp/ with tempfile.gettempdir() for STATUS_FILE (atomic write was already in place) - sync_manager: check _RAW_MAGIC first in _follower_recv_loop routing so magic-tagged frames are always identified correctly regardless of size Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(sync): address fourth round of PR review findings - sync_manager: log INCOMPATIBLE error only on state transition (guard with prev_state != LeaderState.INCOMPATIBLE) so repeated hello packets from an incompatible follower don't spam the log - sync_manager: replace O(n²) bytes concatenation in TCP image receive loop with bytearray + extend() for linear-time accumulation Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(sync): suppress Codacy false positives - display_controller: rename local var 'sh' to 'scroll_h' so Codacy's pattern matcher doesn't confuse it with the 'sh' shell library - sync_manager: add '# nosec B104' to all socket.bind("") calls — binding to all interfaces is intentional (UDP broadcast reception and TCP image server must accept connections from any local interface) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(sync): add nosec B104 to socket creation lines for Codacy Codacy attributes the bind-to-all-interfaces finding to the socket.socket() creation lines (140, 439) rather than the .bind() calls. Added # nosec B104 there too so the suppression is seen at the line Codacy reports. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
dbb53da31d
commit
1c4d5c5271
+119
-155
@@ -90,13 +90,11 @@ class VegasModeCoordinator:
|
||||
self._interrupt_check: Optional[Callable[[], bool]] = None
|
||||
self._interrupt_check_interval: int = 10 # Check every N frames
|
||||
|
||||
# Plugin update tick for keeping data fresh during Vegas mode
|
||||
self._update_tick: Optional[Callable[[], Optional[List[str]]]] = None
|
||||
self._update_tick_interval: float = 1.0 # Tick every 1 second
|
||||
self._update_thread: Optional[threading.Thread] = None
|
||||
self._update_results: Optional[List[str]] = None
|
||||
self._update_results_lock = threading.Lock()
|
||||
self._last_update_tick_time: float = 0.0
|
||||
# Plugin update callback — fired from a background thread inside the loop
|
||||
# so the main loop's _tick_plugin_updates() finds nothing due when Vegas
|
||||
# returns, eliminating the inter-iteration frozen-frame gap.
|
||||
self._update_callback: Optional[Callable[[], None]] = None
|
||||
self._update_tick_running: bool = False
|
||||
|
||||
# Config update tracking
|
||||
self._config_version = 0
|
||||
@@ -139,6 +137,25 @@ class VegasModeCoordinator:
|
||||
"""Check if Vegas mode is currently running."""
|
||||
return self._is_active
|
||||
|
||||
def set_sync_manager(self, sync_manager, follower_position: str = "left") -> None:
|
||||
"""
|
||||
Attach a DisplaySyncManager so Vegas mode sends the follower's portion
|
||||
of the ticker to the second display on every rendered frame.
|
||||
|
||||
Args:
|
||||
sync_manager: DisplaySyncManager instance, or None to disable sync
|
||||
follower_position: "left" (default) or "right" — physical position of
|
||||
the follower display relative to the leader
|
||||
"""
|
||||
if self.render_pipeline:
|
||||
# Don't expose a standalone (no-op) manager to the pipeline — treat it as None
|
||||
if sync_manager is not None and hasattr(sync_manager, 'role'):
|
||||
from src.common.sync_manager import SyncRole
|
||||
if sync_manager.role == SyncRole.STANDALONE:
|
||||
sync_manager = None
|
||||
self.render_pipeline.sync_manager = sync_manager
|
||||
self.render_pipeline.sync_follower_left = (follower_position == "left")
|
||||
|
||||
def set_live_priority_checker(self, checker: Callable[[], Optional[str]]) -> None:
|
||||
"""
|
||||
Set the callback for checking live priority content.
|
||||
@@ -166,24 +183,19 @@ class VegasModeCoordinator:
|
||||
self._interrupt_check = checker
|
||||
self._interrupt_check_interval = max(1, check_interval)
|
||||
|
||||
def set_update_tick(
|
||||
self,
|
||||
callback: Callable[[], Optional[List[str]]],
|
||||
interval: float = 1.0
|
||||
) -> None:
|
||||
def set_update_callback(self, callback: Callable[[], None]) -> None:
|
||||
"""
|
||||
Set the callback for periodic plugin update ticking during Vegas mode.
|
||||
Set a callback for running plugin updates from inside the Vegas loop.
|
||||
|
||||
This keeps plugin data fresh while the Vegas render loop is running.
|
||||
The callback should run scheduled plugin updates and return a list of
|
||||
plugin IDs that were actually updated, or None/empty if no updates occurred.
|
||||
Fired in a daemon background thread every ~4 s so plugin data stays
|
||||
fresh without blocking the render loop. The main loop's
|
||||
_tick_plugin_updates() then finds all intervals already satisfied and
|
||||
returns immediately, collapsing the inter-iteration gap to <1 ms.
|
||||
|
||||
Args:
|
||||
callback: Callable that returns list of updated plugin IDs or None
|
||||
interval: Seconds between update tick calls (default 1.0)
|
||||
callback: Callable with no arguments (typically _tick_plugin_updates)
|
||||
"""
|
||||
self._update_tick = callback
|
||||
self._update_tick_interval = max(0.5, interval)
|
||||
self._update_callback = callback
|
||||
|
||||
def start(self) -> bool:
|
||||
"""
|
||||
@@ -237,9 +249,6 @@ class VegasModeCoordinator:
|
||||
self.stats['total_runtime_seconds'] += time.time() - self._start_time
|
||||
self._start_time = None
|
||||
|
||||
# Wait for in-flight background update before tearing down state
|
||||
self._drain_update_thread()
|
||||
|
||||
# Cleanup components
|
||||
self.render_pipeline.reset()
|
||||
self.stream_manager.reset()
|
||||
@@ -335,83 +344,101 @@ class VegasModeCoordinator:
|
||||
last_fps_log_time = start_time
|
||||
fps_frame_count = 0
|
||||
|
||||
self._last_update_tick_time = start_time
|
||||
|
||||
logger.info("Starting Vegas iteration for %.1fs", duration)
|
||||
|
||||
try:
|
||||
while True:
|
||||
# Check for STATIC mode plugin that should pause scroll
|
||||
static_plugin = self._check_static_plugin_trigger()
|
||||
if static_plugin:
|
||||
if not self._handle_static_pause(static_plugin):
|
||||
# Static pause was interrupted
|
||||
while True:
|
||||
# Check for STATIC mode plugin that should pause scroll
|
||||
static_plugin = self._check_static_plugin_trigger()
|
||||
if static_plugin:
|
||||
if not self._handle_static_pause(static_plugin):
|
||||
# Static pause was interrupted
|
||||
return False
|
||||
# After static pause, skip this segment and continue
|
||||
self.stream_manager.get_next_segment() # Consume the segment
|
||||
continue
|
||||
|
||||
# Run frame
|
||||
if not self.run_frame():
|
||||
# Check why we stopped
|
||||
with self._state_lock:
|
||||
if self._should_stop:
|
||||
return False
|
||||
if self._is_paused:
|
||||
# Paused for live priority - let caller handle
|
||||
return False
|
||||
# After static pause, skip this segment and continue
|
||||
self.stream_manager.get_next_segment() # Consume the segment
|
||||
continue
|
||||
|
||||
# Run frame
|
||||
if not self.run_frame():
|
||||
# Check why we stopped
|
||||
with self._state_lock:
|
||||
if self._should_stop:
|
||||
return False
|
||||
if self._is_paused:
|
||||
# Paused for live priority - let caller handle
|
||||
return False
|
||||
# Sleep for frame interval
|
||||
time.sleep(frame_interval)
|
||||
|
||||
# Sleep for frame interval
|
||||
time.sleep(frame_interval)
|
||||
# Increment frame count and check for interrupt periodically
|
||||
frame_count += 1
|
||||
fps_frame_count += 1
|
||||
|
||||
# Increment frame count and check for interrupt periodically
|
||||
frame_count += 1
|
||||
fps_frame_count += 1
|
||||
# Periodic FPS logging
|
||||
current_time = time.time()
|
||||
if current_time - last_fps_log_time >= fps_log_interval:
|
||||
fps = fps_frame_count / (current_time - last_fps_log_time)
|
||||
logger.info(
|
||||
"Vegas FPS: %.1f (target: %d, frames: %d)",
|
||||
fps, self.vegas_config.target_fps, fps_frame_count
|
||||
)
|
||||
last_fps_log_time = current_time
|
||||
fps_frame_count = 0
|
||||
|
||||
# Periodic FPS logging
|
||||
current_time = time.time()
|
||||
if current_time - last_fps_log_time >= fps_log_interval:
|
||||
fps = fps_frame_count / (current_time - last_fps_log_time)
|
||||
logger.info(
|
||||
"Vegas FPS: %.1f (target: %d, frames: %d)",
|
||||
fps, self.vegas_config.target_fps, fps_frame_count
|
||||
)
|
||||
last_fps_log_time = current_time
|
||||
fps_frame_count = 0
|
||||
if (self._interrupt_check and
|
||||
frame_count % self._interrupt_check_interval == 0):
|
||||
try:
|
||||
if self._interrupt_check():
|
||||
logger.debug(
|
||||
"Vegas interrupted by callback after %d frames",
|
||||
frame_count
|
||||
)
|
||||
return False
|
||||
except Exception:
|
||||
# Log but don't let interrupt check errors stop Vegas
|
||||
logger.exception("Interrupt check failed")
|
||||
|
||||
# Periodic plugin update tick to keep data fresh (non-blocking)
|
||||
self._drive_background_updates()
|
||||
|
||||
if (self._interrupt_check and
|
||||
frame_count % self._interrupt_check_interval == 0):
|
||||
# Fire plugin update tick in a background thread every ~4 s.
|
||||
# Running it here (rather than only between iterations) means the
|
||||
# main loop's _tick_plugin_updates() finds all intervals already
|
||||
# satisfied on return, so the inter-iteration gap is <1 ms and the
|
||||
# display never shows a frozen frame between iterations.
|
||||
_UPDATE_TICK_FRAMES = max(1, int(self.vegas_config.target_fps * 4)) # every 4 s regardless of FPS
|
||||
if (self._update_callback and
|
||||
frame_count % _UPDATE_TICK_FRAMES == 0 and
|
||||
not self._update_tick_running):
|
||||
self._update_tick_running = True
|
||||
def _run_tick(cb=self._update_callback):
|
||||
try:
|
||||
if self._interrupt_check():
|
||||
logger.debug(
|
||||
"Vegas interrupted by callback after %d frames",
|
||||
frame_count
|
||||
)
|
||||
return False
|
||||
except Exception:
|
||||
# Log but don't let interrupt check errors stop Vegas
|
||||
logger.exception("Interrupt check failed")
|
||||
cb()
|
||||
finally:
|
||||
self._update_tick_running = False
|
||||
threading.Thread(
|
||||
target=_run_tick, daemon=True, name="vegas-plugin-tick"
|
||||
).start()
|
||||
|
||||
# Check elapsed time
|
||||
elapsed = time.time() - start_time
|
||||
if elapsed >= duration:
|
||||
break
|
||||
# Check elapsed time
|
||||
elapsed = time.time() - start_time
|
||||
if elapsed >= duration:
|
||||
break
|
||||
|
||||
# Check for cycle completion
|
||||
if self.render_pipeline.is_cycle_complete():
|
||||
break
|
||||
# NOTE: do NOT break on is_cycle_complete() here.
|
||||
# When multi-display sync is active, breaking exits run_iteration()
|
||||
# which causes a 2-3s delay before start_new_cycle() is called on
|
||||
# the next run_iteration(). During that gap the scroll advances into
|
||||
# the pre-roll zone, then start_new_cycle() resets it — producing a
|
||||
# second visible jump on the follower display ~2.5s after the first.
|
||||
#
|
||||
# Instead, run_frame() handles cycle completion directly (it calls
|
||||
# start_new_cycle() in the very next frame, 8ms later), collapsing
|
||||
# the two events into a single clean transition.
|
||||
#
|
||||
# Without sync, the iteration now runs to its full duration and may
|
||||
# cycle content multiple times within one iteration — acceptable for
|
||||
# a continuous ticker.
|
||||
|
||||
logger.info("Vegas iteration completed after %.1fs", time.time() - start_time)
|
||||
return True
|
||||
|
||||
finally:
|
||||
# Ensure background update thread finishes before the main loop
|
||||
# resumes its own _tick_plugin_updates() calls, preventing concurrent
|
||||
# run_scheduled_updates() execution.
|
||||
self._drain_update_thread()
|
||||
logger.info("Vegas iteration completed after %.1fs", time.time() - start_time)
|
||||
return True
|
||||
|
||||
def _check_live_priority(self) -> bool:
|
||||
"""
|
||||
@@ -500,71 +527,6 @@ class VegasModeCoordinator:
|
||||
if self._pending_config is None:
|
||||
self._pending_config_update = False
|
||||
|
||||
def _run_update_tick_background(self) -> None:
|
||||
"""Run the plugin update tick in a background thread.
|
||||
|
||||
Stores results for the render loop to pick up on its next iteration,
|
||||
so the scroll never blocks on API calls.
|
||||
"""
|
||||
try:
|
||||
updated_plugins = self._update_tick()
|
||||
if updated_plugins:
|
||||
with self._update_results_lock:
|
||||
# Accumulate rather than replace to avoid losing notifications
|
||||
# if a previous result hasn't been picked up yet
|
||||
if self._update_results is None:
|
||||
self._update_results = updated_plugins
|
||||
else:
|
||||
self._update_results.extend(updated_plugins)
|
||||
except Exception:
|
||||
logger.exception("Background plugin update tick failed")
|
||||
|
||||
def _drain_update_thread(self, timeout: float = 2.0) -> None:
|
||||
"""Wait for any in-flight background update thread to finish.
|
||||
|
||||
Called when transitioning out of Vegas mode so the main-loop
|
||||
``_tick_plugin_updates`` call doesn't race with a still-running
|
||||
background thread.
|
||||
"""
|
||||
if self._update_thread is not None and self._update_thread.is_alive():
|
||||
self._update_thread.join(timeout=timeout)
|
||||
if self._update_thread.is_alive():
|
||||
logger.warning(
|
||||
"Background update thread did not finish within %.1fs", timeout
|
||||
)
|
||||
|
||||
def _drive_background_updates(self) -> None:
|
||||
"""Collect finished background update results and launch new ticks.
|
||||
|
||||
Safe to call from both the main render loop and the static-pause
|
||||
wait loop so that plugin data stays fresh regardless of which
|
||||
code path is active.
|
||||
"""
|
||||
# 1. Collect results from a previously completed background update
|
||||
with self._update_results_lock:
|
||||
ready_results = self._update_results
|
||||
self._update_results = None
|
||||
if ready_results:
|
||||
for pid in ready_results:
|
||||
self.mark_plugin_updated(pid)
|
||||
|
||||
# 2. Kick off a new background update if interval elapsed and none running
|
||||
current_time = time.time()
|
||||
if (self._update_tick and
|
||||
current_time - self._last_update_tick_time >= self._update_tick_interval):
|
||||
thread_alive = (
|
||||
self._update_thread is not None
|
||||
and self._update_thread.is_alive()
|
||||
)
|
||||
if not thread_alive:
|
||||
self._last_update_tick_time = current_time
|
||||
self._update_thread = threading.Thread(
|
||||
target=self._run_update_tick_background,
|
||||
daemon=True,
|
||||
name="vegas-update-tick",
|
||||
)
|
||||
self._update_thread.start()
|
||||
|
||||
def mark_plugin_updated(self, plugin_id: str) -> None:
|
||||
"""
|
||||
Notify that a plugin's data has been updated.
|
||||
@@ -683,8 +645,10 @@ class VegasModeCoordinator:
|
||||
logger.info("Static pause interrupted by live priority")
|
||||
return False
|
||||
|
||||
# Keep plugin data fresh during static pause
|
||||
self._drive_background_updates()
|
||||
# Yield immediately if multi-display follower mode becomes active
|
||||
if self._interrupt_check and self._interrupt_check():
|
||||
logger.info("Static pause interrupted by sync follower mode")
|
||||
return False
|
||||
|
||||
# Sleep in small increments to remain responsive
|
||||
time.sleep(0.1)
|
||||
|
||||
@@ -52,6 +52,10 @@ class RenderPipeline:
|
||||
self.config = config
|
||||
self.display_manager = display_manager
|
||||
self.stream_manager = stream_manager
|
||||
self.sync_manager = None # Optional DisplaySyncManager — set by coordinator
|
||||
self.sync_follower_left = True # True = follower is LEFT of leader (default)
|
||||
self._sync_send_interval = 1.0 / 90 # raw bytes are cheap; 90fps > follower render rate
|
||||
self._last_sync_send = 0.0
|
||||
|
||||
# Display dimensions (handle both property and method access patterns)
|
||||
self.display_width = (
|
||||
@@ -208,13 +212,14 @@ class RenderPipeline:
|
||||
# total_distance_scrolled >= total_scroll_width + display_width.
|
||||
# That extra display_width of travel causes a "wrap-around" phase
|
||||
# where scroll_position resets to ~0 and the first plugin's content
|
||||
# re-enters from the right — the user sees this 2-3 s window as
|
||||
# "a plugin partially displaying before the next one starts."
|
||||
# re-enters from the right — the user sees this 2-3 s of re-entry
|
||||
# as "a plugin partially displaying before the next one starts."
|
||||
#
|
||||
# We end the cycle as soon as total_distance_scrolled reaches
|
||||
# total_scroll_width (the wrap-around point), before any second-pass
|
||||
# content becomes visible. scroll_helper.is_scroll_complete() is
|
||||
# kept as a fallback for edge-cases where that threshold is skipped.
|
||||
# content becomes visible. The scroll_helper's own is_scroll_complete()
|
||||
# check is kept as a fallback for any edge-cases where that threshold
|
||||
# is never hit.
|
||||
at_wrap_point = (
|
||||
not self._cycle_complete and
|
||||
self.scroll_helper.total_distance_scrolled >= self.scroll_helper.total_scroll_width
|
||||
@@ -228,19 +233,16 @@ class RenderPipeline:
|
||||
"Scroll cycle complete after %.1fs",
|
||||
time.time() - self._cycle_start_time
|
||||
)
|
||||
# Push blank immediately so the hardware never shows
|
||||
# post-wrap content while the coordinator recomposes.
|
||||
# Push blank immediately so the hardware never shows any
|
||||
# post-wrap content while the coordinator recomposes the
|
||||
# next cycle (~100 ms).
|
||||
try:
|
||||
from PIL import Image as _Image
|
||||
blank = _Image.new('RGB', (self.display_width, self.display_height))
|
||||
self.display_manager.image = blank
|
||||
self.display_manager.update_display()
|
||||
except (ImportError, OSError, RuntimeError, ValueError, TypeError, MemoryError) as exc:
|
||||
logger.error(
|
||||
"Failed to push blank frame at cycle end "
|
||||
"(display=%dx%d): %s",
|
||||
self.display_width, self.display_height, exc
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to write blank frame to display at cycle end")
|
||||
return True # Cycle done; coordinator starts new cycle next frame
|
||||
|
||||
# Get visible portion
|
||||
@@ -252,6 +254,15 @@ class RenderPipeline:
|
||||
self.display_manager.image = visible_frame
|
||||
self.display_manager.update_display()
|
||||
|
||||
# Multi-display sync: send scroll position to follower.
|
||||
# The follower renders from its own cached_array (kept identical to the
|
||||
# leader's via TCP image transfer at each new_cycle) at scroll_x ± display_width.
|
||||
if self.sync_manager:
|
||||
now = time.time()
|
||||
if now - self._last_sync_send >= self._sync_send_interval:
|
||||
self._last_sync_send = now
|
||||
self.sync_manager.send_scroll_x(self.scroll_helper.scroll_position)
|
||||
|
||||
# Update scrolling state
|
||||
self.display_manager.set_scrolling_state(True)
|
||||
|
||||
@@ -291,33 +302,38 @@ class RenderPipeline:
|
||||
if self._cycle_complete:
|
||||
return True
|
||||
|
||||
# When multi-display sync is active, defer mid-cycle hot swaps until the
|
||||
# cycle ends naturally. Hot swaps block the render loop for 15-30ms while
|
||||
# the image is rebuilt, causing a freeze+jump that the follower perceives
|
||||
# as a speed-up. Deferring to cycle boundaries keeps transitions clean.
|
||||
# Staging buffer content is still pre-loaded; it just applies at cycle end.
|
||||
if self.sync_manager is not None:
|
||||
return False
|
||||
|
||||
# Check if we need more content in the buffer
|
||||
buffer_status = self.stream_manager.get_buffer_status()
|
||||
if buffer_status['staging_count'] > 0:
|
||||
return True
|
||||
|
||||
# Trigger recompose when pending updates affect visible segments
|
||||
if self.stream_manager.has_pending_updates_for_visible_segments():
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def hot_swap_content(self) -> bool:
|
||||
"""
|
||||
Hot-swap to new composed content.
|
||||
|
||||
Called when staging buffer has updated content or pending updates exist.
|
||||
Preserves scroll position for mid-cycle updates to prevent visual jumps.
|
||||
Called when staging buffer has updated content.
|
||||
Swaps atomically to prevent visual glitches.
|
||||
|
||||
Returns:
|
||||
True if swap occurred
|
||||
"""
|
||||
try:
|
||||
# Save scroll position for mid-cycle updates
|
||||
saved_position = self.scroll_helper.scroll_position
|
||||
saved_total_distance = self.scroll_helper.total_distance_scrolled
|
||||
saved_total_width = max(1, self.scroll_helper.total_scroll_width)
|
||||
was_mid_cycle = not self._cycle_complete
|
||||
# Snapshot position before swap so we can reposition after.
|
||||
# The new image has completely different content — if scroll_position
|
||||
# is left unchanged it lands at an arbitrary mid-content point in the
|
||||
# new image, causing a visible jump on both displays.
|
||||
old_width = self.scroll_helper.total_scroll_width
|
||||
old_pos = self.scroll_helper.scroll_position
|
||||
|
||||
# Process any pending updates
|
||||
self.stream_manager.process_updates()
|
||||
@@ -325,20 +341,24 @@ class RenderPipeline:
|
||||
|
||||
# Recompose with updated content
|
||||
if self.compose_scroll_content():
|
||||
# Map scroll position proportionally into the new image width so
|
||||
# we resume at the same relative progress through the content.
|
||||
# This keeps the visual tempo consistent and avoids the jump that
|
||||
# occurred when old scroll_position landed arbitrarily in new image.
|
||||
new_width = self.scroll_helper.total_scroll_width
|
||||
if old_width > 0 and new_width > 0:
|
||||
ratio = (old_pos % old_width) / old_width
|
||||
self.scroll_helper.scroll_position = ratio * new_width
|
||||
else:
|
||||
self.scroll_helper.scroll_position = 0.0
|
||||
|
||||
self.stats['hot_swaps'] += 1
|
||||
# Restore scroll position for mid-cycle updates so the
|
||||
# scroll continues from where it was instead of jumping to 0
|
||||
if was_mid_cycle:
|
||||
new_total_width = max(1, self.scroll_helper.total_scroll_width)
|
||||
progress_ratio = min(saved_total_distance / saved_total_width, 0.999)
|
||||
self.scroll_helper.total_distance_scrolled = progress_ratio * new_total_width
|
||||
self.scroll_helper.scroll_position = min(
|
||||
saved_position,
|
||||
float(new_total_width - 1)
|
||||
)
|
||||
self.scroll_helper.scroll_complete = False
|
||||
self._cycle_complete = False
|
||||
logger.debug("Hot-swap completed (mid_cycle_restore=%s)", was_mid_cycle)
|
||||
logger.debug(
|
||||
"Hot-swap completed: scroll repositioned %.0f→%.0f (%.1f%% of new %dpx image)",
|
||||
old_pos, self.scroll_helper.scroll_position,
|
||||
(self.scroll_helper.scroll_position / new_width * 100) if new_width else 0,
|
||||
new_width,
|
||||
)
|
||||
return True
|
||||
|
||||
return False
|
||||
@@ -373,7 +393,29 @@ class RenderPipeline:
|
||||
return False
|
||||
|
||||
# Compose new scroll content
|
||||
return self.compose_scroll_content()
|
||||
result = self.compose_scroll_content()
|
||||
|
||||
if result and self.sync_manager:
|
||||
# When sync is active, start the leader at display_width instead of 0.
|
||||
# This skips the initial black gap so the leader immediately shows content.
|
||||
# The follower starts at position 0 (the gap) which looks like a clean
|
||||
# blank transition rather than near-end content wrapping around.
|
||||
self.scroll_helper.scroll_position = float(self.display_width)
|
||||
|
||||
if result and self.sync_manager:
|
||||
# Signal follower that a new cycle started (triggers its own rebuild)
|
||||
self.sync_manager.send_new_cycle()
|
||||
# Push the actual scroll image over TCP so follower has identical pixels.
|
||||
# Done in a background thread to not block the render loop (~15ms transfer).
|
||||
if self.scroll_helper.cached_image is not None:
|
||||
import threading as _t
|
||||
_t.Thread(
|
||||
target=self.sync_manager.send_scroll_image,
|
||||
args=(self.scroll_helper.cached_image,),
|
||||
daemon=True, name="sync-image-push"
|
||||
).start()
|
||||
|
||||
return result
|
||||
|
||||
def get_current_scroll_info(self) -> Dict[str, Any]:
|
||||
"""Get current scroll state information."""
|
||||
|
||||
Reference in New Issue
Block a user