fix(vegas): lock the scroll to the panel refresh; encode the preview off the render thread

Vegas advanced by elapsed time, blended neighbouring columns every frame,
and paced itself with a sleep to target_fps. On hdpi (4x128x64 on one
chain, a 120Hz cap the chain cannot reach, ~95-100Hz real) that ran at
73fps with target 90 and ~89fps with target 125: the sleep drifted
against the refresh and missed a vsync every few frames, and the blend
read as shimmer on the panel (and as "anti-aliased" text in the preview).

smooth_scroll now means the crisp pacing the plugin tickers already use:
a whole number of pixels per presented frame, each held for frame_hold
refreshes, with SwapOnVSync as the clock. The speed is solved against the
panel's measured refresh, timed from our own swaps once scrolling starts,
because the configured limit is only a cap -- at "120Hz" 90px/s solves to
3px every 4 refreshes, at the real ~97Hz to 1px every refresh. The old
blend stays available as sub_pixel_blend (default off).

With the web preview open, the render thread also PNG-encoded the whole
512x64 frame five times a second, 12-14ms each -- longer than a refresh.
Mid-scroll that encode now runs on a single-slot writer thread (Pillow
releases the GIL while compressing); static frames still write inline.

Measured on hdpi, 3-minute soak with the preview open: 3 of 17,280
frames held an extra refresh (0.02%), down from ~6-20%.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-23 21:41:15 -04:00
co-authored by Claude Opus 5.5
parent f3894916a9
commit 0a2ce58026
10 changed files with 447 additions and 69 deletions
+2 -1
View File
@@ -127,7 +127,8 @@ Read by `src/vegas_mode/config.py` (`VegasScrollConfig.from_config`). See
| `min_content_separation` | int, `24` | | `min_content_separation` | int, `24` |
| `min_cut_gap` | int, `6` | | `min_cut_gap` | int, `6` |
| `continuous_scroll` | bool, `true` | | `continuous_scroll` | bool, `true` |
| `smooth_scroll` | bool, `true` | | `smooth_scroll` | bool, `true` — move a whole number of pixels per panel refresh, locked to vsync. `scroll_speed` is snapped to the nearest speed the panel can show that way (at 95Hz: 95, 47.5, 31.7 px/s…), measured against the panel's real refresh rate once scrolling starts |
| `sub_pixel_blend` | bool, `false` — the older smoothing: advance by elapsed time and blend neighbouring pixel columns. Looks anti-aliased in the web preview but shimmers on the panel and is not locked to the refresh. Overrides `smooth_scroll` when on |
| `extend_threshold_screens` | float, `2.0` | | `extend_threshold_screens` | float, `2.0` |
| `auto_trim` | bool, `true` | | `auto_trim` | bool, `true` |
| `trim_threshold` | int, `10` | | `trim_threshold` | int, `10` |
+67 -18
View File
@@ -203,6 +203,10 @@ class DisplayManager:
self._last_snapshot_touch_ts = 0.0 self._last_snapshot_touch_ts = 0.0
self._last_snapshot_digest: Optional[int] = None self._last_snapshot_digest: Optional[int] = None
self._snapshot_dir_prepared = False self._snapshot_dir_prepared = False
# Background writer used mid-scroll; see _write_snapshot_if_due.
self._snapshot_cond = threading.Condition()
self._snapshot_pending: Optional[Image.Image] = None
self._snapshot_thread: Optional[threading.Thread] = None
self._viewer_check_ts = 0.0 self._viewer_check_ts = 0.0
self._viewer_fresh = False self._viewer_fresh = False
self._viewer_was_fresh = False self._viewer_was_fresh = False
@@ -1669,7 +1673,41 @@ class DisplayManager:
self._last_snapshot_touch_ts = now self._last_snapshot_touch_ts = now
return return
# WRITE: ensure directory permissions once, not per frame # WRITE. Mid-scroll the PNG encode goes to a background thread: at
# 512x64 it takes 12-14ms on a Pi 4, longer than a 95Hz refresh,
# so on the render thread every preview write made the next swap
# miss its vsync -- five visible hitches a second, but only while
# someone had the web preview open. Pillow releases the GIL while
# it compresses, so the encode no longer holds the loop up. Static
# frames still write inline: nothing is moving to disturb.
if self.is_currently_scrolling():
self._queue_snapshot(self.image.copy())
else:
self._save_snapshot(self.image)
self._last_snapshot_ts = now
self._last_snapshot_touch_ts = now
self._last_snapshot_digest = digest
except Exception as e:
self._log_snapshot_failure(e)
def _log_snapshot_failure(self, error: Exception) -> None:
# Snapshot failures must never break display — but they must not
# be silent either: the snapshot's mtime is the web UI's display
# mirror AND its hardware-liveness proxy, so a quietly failing
# write freezes the mirror and makes health checks lie (seen in
# the field: a stale root-owned /tmp file froze it for a day).
# Warn at most once per 5 minutes to avoid log spam.
now = time.time()
if (now - self._snapshot_fail_log_ts) > 300:
self._snapshot_fail_log_ts = now
logger.warning("Snapshot write failing (web preview/health "
"mirror is stale): %s", error)
else:
logger.debug(f"Snapshot write skipped: {error}")
def _save_snapshot(self, image: Image.Image) -> None:
"""Encode ``image`` to the snapshot path atomically. Raises on failure."""
# Ensure directory permissions once, not per frame
snapshot_path_obj = Path(self._snapshot_path) snapshot_path_obj = Path(self._snapshot_path)
if not self._snapshot_dir_prepared: if not self._snapshot_dir_prepared:
# Never modify /tmp permissions - it has special system # Never modify /tmp permissions - it has special system
@@ -1693,7 +1731,7 @@ class DisplayManager:
prefix=f".{snapshot_path_obj.name}.", suffix=".tmp") prefix=f".{snapshot_path_obj.name}.", suffix=".tmp")
try: try:
with os.fdopen(_fd, "wb") as _f: with os.fdopen(_fd, "wb") as _f:
self.image.save(_f, format='PNG') image.save(_f, format='PNG')
os.chmod(tmp_path, 0o644) os.chmod(tmp_path, 0o644)
os.replace(tmp_path, self._snapshot_path) os.replace(tmp_path, self._snapshot_path)
except Exception: except Exception:
@@ -1704,25 +1742,36 @@ class DisplayManager:
except OSError: except OSError:
pass pass
# Fallback to direct save if replace not supported # Fallback to direct save if replace not supported
self.image.save(self._snapshot_path, format='PNG') image.save(self._snapshot_path, format='PNG')
# Set proper file permissions after saving # Set proper file permissions after saving
try: try:
ensure_file_permissions(snapshot_path_obj, get_assets_file_mode()) ensure_file_permissions(snapshot_path_obj, get_assets_file_mode())
except Exception: except Exception:
pass pass
self._last_snapshot_ts = now
self._last_snapshot_touch_ts = now def _queue_snapshot(self, image: Image.Image) -> None:
self._last_snapshot_digest = digest """Hand a frame to the snapshot writer thread; the newest frame wins.
One slot, not a queue: if the writer is still encoding when the next
frame is due, the waiting frame is simply replaced. The preview wants
the latest frame, and a backlog would only cost memory and CPU.
"""
with self._snapshot_cond:
self._snapshot_pending = image
if self._snapshot_thread is None or not self._snapshot_thread.is_alive():
self._snapshot_thread = threading.Thread(
target=self._snapshot_writer, daemon=True,
name="snapshot-writer")
self._snapshot_thread.start()
self._snapshot_cond.notify()
def _snapshot_writer(self) -> None:
while True:
with self._snapshot_cond:
while self._snapshot_pending is None:
self._snapshot_cond.wait()
image, self._snapshot_pending = self._snapshot_pending, None
try:
self._save_snapshot(image)
except Exception as e: except Exception as e:
# Snapshot failures must never break display — but they must not self._log_snapshot_failure(e)
# be silent either: the snapshot's mtime is the web UI's display
# mirror AND its hardware-liveness proxy, so a quietly failing
# write freezes the mirror and makes health checks lie (seen in
# the field: a stale root-owned /tmp file froze it for a day).
# Warn at most once per 5 minutes to avoid log spam.
if (now - self._snapshot_fail_log_ts) > 300:
self._snapshot_fail_log_ts = now
logger.warning("Snapshot write failing (web preview/health "
"mirror is stale): %s", e)
else:
logger.debug(f"Snapshot write skipped: {e}")
+16 -5
View File
@@ -58,13 +58,22 @@ class VegasModeConfig:
# switched off at the start of every cycle. # switched off at the start of every cycle.
lead_in_width: int = 0 lead_in_width: int = 0
# Blend between neighbouring pixel positions so motion happens at the frame # Lock motion to the panel: a whole number of pixels per presented frame,
# rate rather than the scroll speed. With integer positioning the number of # each frame held for a whole number of refreshes, with SwapOnVSync as the
# distinct frames per second equals scroll_speed, so at 50px/s the motion is # clock (see src/common/scroll_config.py). scroll_speed is snapped to the
# 50 discrete 1px steps however fast the loop runs. The trade is a slight # nearest speed the panel can show that way. Off falls back to advancing by
# horizontal softening of text, since each frame is a blend of two positions. # elapsed time, which drifts against the refresh and judders.
smooth_scroll: bool = True smooth_scroll: bool = True
# The older way of smoothing: advance by elapsed time and blend the two
# neighbouring pixel positions each frame. It looks anti-aliased in the web
# preview, but on the panel the blended columns shimmer (the library's
# brightness curve makes a 50% blend far dimmer than half), text softens,
# and the loop is not tied to the refresh, so it still misses frames.
# Measured on a 512x64 chain at 95Hz: 73-89fps, p99 20-28ms. Takes
# precedence over smooth_scroll's whole-pixel pacing when on.
sub_pixel_blend: bool = False
# Keep one continuous strip, extending it with the next group of plugins as # Keep one continuous strip, extending it with the next group of plugins as
# the scroll approaches the end, instead of composing a fresh strip and # the scroll approaches the end, instead of composing a fresh strip and
# swapping it in. A swap stops the motion, substitutes every pixel at once # swapping it in. A swap stops the motion, substitutes every pixel at once
@@ -192,6 +201,7 @@ class VegasModeConfig:
vegas_config.get('min_content_separation', 24)), vegas_config.get('min_content_separation', 24)),
min_cut_gap=int(vegas_config.get('min_cut_gap', 6)), min_cut_gap=int(vegas_config.get('min_cut_gap', 6)),
smooth_scroll=vegas_config.get('smooth_scroll', True), smooth_scroll=vegas_config.get('smooth_scroll', True),
sub_pixel_blend=bool(vegas_config.get('sub_pixel_blend', False)),
continuous_scroll=vegas_config.get('continuous_scroll', True), continuous_scroll=vegas_config.get('continuous_scroll', True),
extend_threshold_screens=float( extend_threshold_screens=float(
vegas_config.get('extend_threshold_screens', 2.0)), vegas_config.get('extend_threshold_screens', 2.0)),
@@ -232,6 +242,7 @@ class VegasModeConfig:
'min_content_separation': self.min_content_separation, 'min_content_separation': self.min_content_separation,
'min_cut_gap': self.min_cut_gap, 'min_cut_gap': self.min_cut_gap,
'smooth_scroll': self.smooth_scroll, 'smooth_scroll': self.smooth_scroll,
'sub_pixel_blend': self.sub_pixel_blend,
'continuous_scroll': self.continuous_scroll, 'continuous_scroll': self.continuous_scroll,
'extend_threshold_screens': self.extend_threshold_screens, 'extend_threshold_screens': self.extend_threshold_screens,
'auto_trim': self.auto_trim, 'auto_trim': self.auto_trim,
+7 -5
View File
@@ -420,7 +420,6 @@ class VegasModeCoordinator:
# Update static mode plugin list on iteration start # Update static mode plugin list on iteration start
self._update_static_mode_plugins() self._update_static_mode_plugins()
frame_interval = self.vegas_config.get_frame_interval()
if self.vegas_config.continuous_scroll: if self.vegas_config.continuous_scroll:
# The strip is continuously extended and trimmed, so its width says # The strip is continuously extended and trimmed, so its width says
# nothing about how long to run. This is only how often control # nothing about how long to run. This is only how often control
@@ -488,7 +487,10 @@ class VegasModeCoordinator:
# quarter of the budget spent not rendering. Subtracting the work # quarter of the budget spent not rendering. Subtracting the work
# already done keeps the pacing target while reclaiming that time, # already done keeps the pacing target while reclaiming that time,
# and yields the GIL either way so other threads still run. # and yields the GIL either way so other threads still run.
# Read every frame: a config change applied mid-iteration can
# switch between crisp and blended pacing.
frame_elapsed = time.monotonic() - frame_started frame_elapsed = time.monotonic() - frame_started
frame_interval = self.render_pipeline.frame_interval
time.sleep(max(0.0, frame_interval - frame_elapsed)) time.sleep(max(0.0, frame_interval - frame_elapsed))
# Measured before the sleep: time spent working, not pacing. # Measured before the sleep: time spent working, not pacing.
@@ -518,20 +520,20 @@ class VegasModeCoordinator:
if current_time - last_fps_log_time >= fps_log_interval: if current_time - last_fps_log_time >= fps_log_interval:
fps = fps_frame_count / (current_time - last_fps_log_time) fps = fps_frame_count / (current_time - last_fps_log_time)
p99 = _percentile(sorted(frame_times), 0.99) p99 = _percentile(sorted(frame_times), 0.99)
target = self.vegas_config.target_fps target = self.render_pipeline.target_fps
degraded = target > 0 and fps < target * _FPS_HEALTHY_FRACTION degraded = target > 0 and fps < target * _FPS_HEALTHY_FRACTION
due = (current_time - self._fps_last_health_log due = (current_time - self._fps_last_health_log
>= _FPS_HEARTBEAT_INTERVAL) >= _FPS_HEARTBEAT_INTERVAL)
if degraded or self._fps_was_degraded or due: if degraded or self._fps_was_degraded or due:
logger.info( logger.info(
"Vegas FPS: %.1f (target: %d, frames: %d) p99 %.1fms worst %.1fms", "Vegas FPS: %.1f (target: %.0f, frames: %d) p99 %.1fms worst %.1fms",
fps, target, fps_frame_count, fps, target, fps_frame_count,
p99 * 1000.0, frame_worst * 1000.0 p99 * 1000.0, frame_worst * 1000.0
) )
self._fps_last_health_log = current_time self._fps_last_health_log = current_time
else: else:
logger.debug( logger.debug(
"Vegas FPS: %.1f (target: %d, frames: %d) p99 %.1fms worst %.1fms", "Vegas FPS: %.1f (target: %.0f, frames: %d) p99 %.1fms worst %.1fms",
fps, target, fps_frame_count, fps, target, fps_frame_count,
p99 * 1000.0, frame_worst * 1000.0 p99 * 1000.0, frame_worst * 1000.0
) )
@@ -559,7 +561,7 @@ class VegasModeCoordinator:
# main loop's _tick_plugin_updates() finds all intervals already # main loop's _tick_plugin_updates() finds all intervals already
# satisfied on return, so the inter-iteration gap is <1 ms and the # satisfied on return, so the inter-iteration gap is <1 ms and the
# display never shows a frozen frame between iterations. # 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 _UPDATE_TICK_FRAMES = max(1, int(self.render_pipeline.target_fps * 4)) # every 4 s regardless of FPS
if (self._update_callback and if (self._update_callback and
frame_count % _UPDATE_TICK_FRAMES == 0 and frame_count % _UPDATE_TICK_FRAMES == 0 and
not self._update_tick_running): not self._update_tick_running):
+127 -3
View File
@@ -13,6 +13,7 @@ from collections import deque
from typing import Optional, List, Any, Dict, Deque, TYPE_CHECKING from typing import Optional, List, Any, Dict, Deque, TYPE_CHECKING
from PIL import Image from PIL import Image
from src.common.scroll_config import solve_crisp
from src.common.scroll_helper import ScrollHelper from src.common.scroll_helper import ScrollHelper
from src.vegas_mode.config import VegasModeConfig from src.vegas_mode.config import VegasModeConfig
from src.vegas_mode.geometry import separation_gap from src.vegas_mode.geometry import separation_gap
@@ -40,6 +41,13 @@ class RenderPipeline:
# stalls land in separate moments rather than one run of hitches. # stalls land in separate moments rather than one run of hitches.
DEFERRED_DRAIN_INTERVAL = 2.0 DEFERRED_DRAIN_INTERVAL = 2.0
# Swaps timed before trusting a refresh measurement: about a second.
REFRESH_SAMPLES = 96
# Frames skipped first, while the hold from whatever ran before settles.
REFRESH_WARMUP_FRAMES = 8
# A panel measured within this fraction of its cap is keeping up with it.
REFRESH_TOLERANCE = 0.03
def __init__( def __init__(
self, self,
config: VegasModeConfig, config: VegasModeConfig,
@@ -79,6 +87,11 @@ class RenderPipeline:
logger logger
) )
# The panel's real refresh rate, measured from our own vsync-blocked
# swaps once scrolling starts. None until then; see _measure_refresh.
self._measured_hz: Optional[float] = None
self._swap_times: Deque[float] = deque(maxlen=self.REFRESH_SAMPLES + 1)
# Configure scroll helper # Configure scroll helper
self._configure_scroll_helper() self._configure_scroll_helper()
@@ -122,9 +135,35 @@ class RenderPipeline:
def _configure_scroll_helper(self) -> None: def _configure_scroll_helper(self) -> None:
"""Configure ScrollHelper with current settings.""" """Configure ScrollHelper with current settings."""
self.scroll_helper.set_frame_based_scrolling(self.config.frame_based_scrolling)
self.scroll_helper.set_scroll_delay(self.config.scroll_delay) self.scroll_helper.set_scroll_delay(self.config.scroll_delay)
self.scroll_helper.set_sub_pixel_scrolling(self.config.smooth_scroll) self.scroll_helper.set_sub_pixel_scrolling(self.config.sub_pixel_blend)
# With smooth_scroll the strip moves a whole number of pixels per
# presented frame, each frame held for frame_hold panel refreshes, and
# SwapOnVSync is the clock -- the same crisp pacing the plugin tickers
# use (src/common/scroll_config.py). The time-based path below has no
# fixed relation to the refresh: at 90px/s on a panel refreshing at
# 95Hz every frame lands 0.95px on, so text is re-blended at a
# different phase each refresh, and any frame that misses a vsync is
# followed by a double step. Measured on a 512x64 chain: 73fps against
# a 95Hz panel, p99 21-28ms -- a visible hitch every few frames.
self._crisp = None
self._frame_hold = 1
if self.config.smooth_scroll and not self.config.sub_pixel_blend:
self._crisp = solve_crisp(self.config.scroll_speed, self._refresh_hz())
self._frame_hold = self._crisp.frame_hold
self.scroll_helper.set_frame_based_scrolling(False)
self.scroll_helper.set_scroll_speed(self._crisp.pixels_per_second)
self.scroll_helper.set_pixels_per_frame(self._crisp.pixels_per_frame)
logger.info(
"Vegas scroll: %s (asked for %d px/s on a %.0fHz panel)",
self._crisp.describe(), self.config.scroll_speed, self._refresh_hz()
)
self._apply_dynamic_duration_settings()
return
self.scroll_helper.set_pixels_per_frame(None)
self.scroll_helper.set_frame_based_scrolling(self.config.frame_based_scrolling)
# Config scroll_speed is always pixels per second, but ScrollHelper # Config scroll_speed is always pixels per second, but ScrollHelper
# takes it in different units depending on frame_based_scrolling: # takes it in different units depending on frame_based_scrolling:
@@ -139,6 +178,9 @@ class RenderPipeline:
self.scroll_helper.set_scroll_speed(pixels_per_frame) self.scroll_helper.set_scroll_speed(pixels_per_frame)
else: else:
self.scroll_helper.set_scroll_speed(self.config.scroll_speed) self.scroll_helper.set_scroll_speed(self.config.scroll_speed)
self._apply_dynamic_duration_settings()
def _apply_dynamic_duration_settings(self) -> None:
self.scroll_helper.set_dynamic_duration_settings( self.scroll_helper.set_dynamic_duration_settings(
enabled=self.config.dynamic_duration_enabled, enabled=self.config.dynamic_duration_enabled,
min_duration=self.config.min_cycle_duration, min_duration=self.config.min_cycle_duration,
@@ -146,6 +188,87 @@ class RenderPipeline:
buffer=0.1 # 10% buffer buffer=0.1 # 10% buffer
) )
def _cap_hz(self) -> float:
"""The panel's refresh cap, as the display manager reports it."""
try:
hz = float(getattr(self.display_manager, 'refresh_hz', 0) or 0)
except (TypeError, ValueError):
hz = 0.0
return hz if hz > 0 else 100.0
def _refresh_hz(self) -> float:
"""The refresh to solve the crisp speed against: measured, else the cap."""
return self._measured_hz or self._cap_hz()
def _measure_refresh(self) -> None:
"""Time our swaps to learn the rate the panel really refreshes at.
limit_refresh_rate_hz is a cap, not a rate. A long single chain cannot
reach a high one: 4x128x64 at pwm_bits 8 refreshes at ~95Hz under a
120Hz cap. Solving against the cap then picks a speed built for a
refresh the panel never delivers -- 90px/s at "120Hz" is 3px every 4
refreshes, visibly jumpy, where the real 95Hz allows 1px every refresh.
SwapOnVSync blocks for frame_hold refreshes, so the median gap between
swaps is frame_hold refresh periods. The median ignores the odd frame
that missed its vsync or waited on a recompose. Measured once: the
refresh only changes with the hardware config, which restarts us.
"""
if self._crisp is None or self._measured_hz is not None:
return
if getattr(self.display_manager, 'matrix', None) is None:
return # No hardware: nothing blocks, so there is nothing to time.
if self.stats['frames_rendered'] < self.REFRESH_WARMUP_FRAMES:
return
self._swap_times.append(time.monotonic())
if len(self._swap_times) <= self.REFRESH_SAMPLES:
return
times = list(self._swap_times)
gaps = sorted(b - a for a, b in zip(times, times[1:]))
median = gaps[len(gaps) // 2]
self._swap_times.clear()
if median <= 0:
return
measured = self._frame_hold / median
cap = self._cap_hz()
if measured >= cap * (1.0 - self.REFRESH_TOLERANCE):
self._measured_hz = cap
return
self._measured_hz = round(measured, 1)
logger.info(
"Vegas: panel refreshes at %.1fHz, below its %.0fHz cap; "
"re-solving the scroll speed for the real rate",
self._measured_hz, cap
)
self._configure_scroll_helper()
@property
def target_fps(self) -> float:
"""Frames per second this scroll presents when it keeps up."""
if self._crisp is not None:
return self._crisp.frames_per_second
return float(self.config.target_fps)
@property
def frame_interval(self) -> float:
"""Shortest time the render loop should spend on one frame.
With crisp pacing SwapOnVSync already blocks for frame_hold refreshes,
so this is only a floor for when the swap does not block (no hardware,
or the emulator). It must not exceed the real refresh period: a loop
that sleeps even slightly longer than the panel drifts against it and
misses a refresh every few frames -- which is what target_fps 90 on a
95Hz panel did. The configured cap is at least the real refresh, so
hold / cap never exceeds hold real periods -- the measured rate is
deliberately not used here.
"""
if self._crisp is not None:
# 0.9: the floor must sit strictly below the real period, or the
# surplus accumulates frame over frame until one misses.
return 0.9 * self._frame_hold / self._cap_hz()
return self._frame_interval
def compose_scroll_content(self) -> bool: def compose_scroll_content(self) -> bool:
""" """
Compose content from stream manager into scrollable image. Compose content from stream manager into scrollable image.
@@ -570,10 +693,11 @@ class RenderPipeline:
self.sync_manager.send_scroll_x(self.scroll_helper.scroll_position) self.sync_manager.send_scroll_x(self.scroll_helper.scroll_position)
# Update scrolling state # Update scrolling state
self.display_manager.set_scrolling_state(True) self.display_manager.set_scrolling_state(True, self._frame_hold)
# Track statistics # Track statistics
self.stats['frames_rendered'] += 1 self.stats['frames_rendered'] += 1
self._measure_refresh()
frame_time = time.time() - frame_start frame_time = time.time() - frame_start
self._track_frame_time(frame_time) self._track_frame_time(frame_time)
+54
View File
@@ -357,3 +357,57 @@ class TestFrameHoldLifetime:
assert dm._frame_hold == 1 assert dm._frame_hold == 1
finally: finally:
dm.set_scrolling_state(False) dm.set_scrolling_state(False)
class TestSnapshotOffRenderThread:
"""Mid-scroll, the preview PNG is encoded off the render thread.
At 512x64 the encode takes 12-14ms on a Pi 4 -- longer than a refresh --
so doing it inline made the next swap miss its vsync five times a second
whenever the web preview was open.
"""
def _record_saves(self, dm, monkeypatch):
import threading
threads = []
done = threading.Event()
real = dm._save_snapshot
def recording(image):
threads.append(threading.current_thread().name)
real(image)
done.set()
monkeypatch.setattr(dm, "_save_snapshot", recording)
return threads, done
def _due(self, dm, tmp_path, colour):
dm._snapshot_path = str(tmp_path / "snap.png")
dm._last_snapshot_ts = 0.0
dm._last_snapshot_touch_ts = 0.0
dm._last_snapshot_digest = None
dm.draw.rectangle([0, 0, 10, 4], fill=colour)
def test_scrolling_frames_are_encoded_on_the_writer_thread(
self, dm, tmp_path, monkeypatch):
import threading
threads, done = self._record_saves(dm, monkeypatch)
self._due(dm, tmp_path, (0, 255, 255))
dm.set_scrolling_state(True)
try:
dm.update_display()
assert done.wait(5), "the snapshot writer never wrote the frame"
finally:
dm.set_scrolling_state(False)
assert threads == ["snapshot-writer"]
assert threads[0] != threading.current_thread().name
assert os.path.exists(dm._snapshot_path)
def test_static_frames_are_still_written_inline(
self, dm, tmp_path, monkeypatch):
import threading
threads, _ = self._record_saves(dm, monkeypatch)
self._due(dm, tmp_path, (255, 0, 255))
dm.set_scrolling_state(False)
dm.update_display()
assert threads == [threading.current_thread().name]
+3
View File
@@ -140,6 +140,9 @@ def vegas_coordinator(controller):
'enabled': True, 'max_cycle_duration': VEGAS_ITERATION_SECONDS}}}) 'enabled': True, 'max_cycle_duration': VEGAS_ITERATION_SECONDS}}})
assert coord.vegas_config.continuous_scroll assert coord.vegas_config.continuous_scroll
coord.render_pipeline = MagicMock() coord.render_pipeline = MagicMock()
# Real numbers: the loop sleeps and reports against these.
coord.render_pipeline.frame_interval = coord.vegas_config.get_frame_interval()
coord.render_pipeline.target_fps = float(coord.vegas_config.target_fps)
coord.stream_manager = MagicMock() coord.stream_manager = MagicMock()
coord.display_manager = controller.display_manager coord.display_manager = controller.display_manager
coord.stats = {'cycles_completed': 0, 'interruptions': 0} coord.stats = {'cycles_completed': 0, 'interruptions': 0}
+130
View File
@@ -0,0 +1,130 @@
"""Vegas scrolls in whole pixels locked to the panel refresh.
It used to advance by elapsed time, blend neighbouring columns, and pace itself
with a sleep to target_fps. On a 512x64 chain refreshing at 95Hz that ran at
73-89fps with p99 frames of 20-28ms: the sleep drifted against the refresh and
missed a vsync every few frames, and the blend shimmered on the panel.
"""
import sys
from pathlib import Path
from unittest.mock import patch
from PIL import Image
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from src.common.scroll_config import solve_crisp # noqa: E402
from src.vegas_mode import render_pipeline as rp_module # noqa: E402
from src.vegas_mode.config import VegasModeConfig # noqa: E402
from src.vegas_mode.render_pipeline import RenderPipeline # noqa: E402
W, H = 128, 32
class FakeStream:
def get_grouped_content_for_composition(self):
return [('a', [Image.new('RGB', (4000, H), (255, 255, 255))])]
def get_active_plugin_ids(self):
return ['a']
class FakeDM:
width = W
height = H
def __init__(self, refresh_hz=100.0, hardware=True):
self.refresh_hz = refresh_hz
self.matrix = object() if hardware else None
self.image = Image.new('RGB', (W, H))
self.holds = []
def set_scrolling_state(self, is_scrolling, frame_hold=1):
self.holds.append(frame_hold)
def update_display(self):
pass
def _pipeline(dm=None, **cfg):
p = RenderPipeline(VegasModeConfig(lead_in_width=0, **cfg), dm or FakeDM(),
FakeStream())
assert p.compose_scroll_content()
return p
def test_default_steps_whole_pixels_and_holds_frames():
dm = FakeDM(refresh_hz=100.0)
p = _pipeline(dm, scroll_speed=50)
want = solve_crisp(50, 100.0)
assert p.scroll_helper.fixed_pixels_per_frame == want.pixels_per_frame
assert not p.scroll_helper.sub_pixel_scrolling
before = p.scroll_helper.scroll_position
p.render_frame()
assert p.scroll_helper.scroll_position - before == want.pixels_per_frame
# The hold is what makes 1px every 2 refreshes 50px/s rather than 100.
assert dm.holds[-1] == want.frame_hold == 2
assert p.target_fps == want.frames_per_second
def test_sleep_floor_stays_below_the_refresh_period():
# A floor at or above the real period accumulates until a frame misses.
p = _pipeline(FakeDM(refresh_hz=100.0), scroll_speed=50)
assert p.frame_interval < p._frame_hold / 100.0
def test_sub_pixel_blend_keeps_the_old_time_based_blend():
dm = FakeDM()
p = _pipeline(dm, sub_pixel_blend=True, target_fps=90)
assert p.scroll_helper.fixed_pixels_per_frame is None
assert p.scroll_helper.sub_pixel_scrolling
p.render_frame()
assert dm.holds[-1] == 1
assert p.frame_interval == 1.0 / 90
assert p.target_fps == 90
def _run_swaps(p, period, frames):
"""Render `frames` frames whose swaps are `period` seconds apart."""
clock = [1000.0]
def monotonic():
return clock[0]
with patch.object(rp_module.time, 'monotonic', monotonic):
for _ in range(frames):
p.render_frame()
clock[0] += period
def test_a_panel_below_its_cap_is_measured_and_the_speed_re_solved():
# 4x128x64 on one chain: capped at 120Hz, really 95Hz. Against the cap
# 90px/s solves to 3px every 4 refreshes; against 95Hz, 1px every one.
p = _pipeline(FakeDM(refresh_hz=120.0), scroll_speed=90)
assert p._crisp.pixels_per_frame == 3
frames = RenderPipeline.REFRESH_WARMUP_FRAMES + RenderPipeline.REFRESH_SAMPLES + 2
_run_swaps(p, p._frame_hold / 95.0, frames)
assert p._measured_hz == 95.0
assert (p._crisp.pixels_per_frame, p._crisp.frame_hold) == (1, 1)
# The floor still comes from the cap, not the measurement.
assert p.frame_interval < 1 / 95.0
def test_a_panel_that_keeps_up_with_its_cap_is_left_alone():
p = _pipeline(FakeDM(refresh_hz=100.0), scroll_speed=50)
crisp = p._crisp
frames = RenderPipeline.REFRESH_WARMUP_FRAMES + RenderPipeline.REFRESH_SAMPLES + 2
_run_swaps(p, p._frame_hold / 99.5, frames)
assert p._measured_hz == 100.0
assert p._crisp == crisp
def test_no_measurement_without_hardware():
# Nothing blocks in the emulator, so swap gaps say nothing about a panel.
p = _pipeline(FakeDM(refresh_hz=120.0, hardware=False), scroll_speed=90)
frames = RenderPipeline.REFRESH_WARMUP_FRAMES + RenderPipeline.REFRESH_SAMPLES + 2
_run_swaps(p, 1 / 50.0, frames)
assert p._measured_hz is None
+4
View File
@@ -841,6 +841,10 @@ class TestCycleEndsBeforeWrap:
return p return p
def _advance_to(self, pipeline, distance): def _advance_to(self, pipeline, distance):
# render_frame() steps before it checks, and a whole-pixel pace steps
# a fixed amount; start one step short so the checked frame is at
# `distance`.
distance -= pipeline.scroll_helper.fixed_pixels_per_frame or 0
pipeline.scroll_helper.total_distance_scrolled = distance pipeline.scroll_helper.total_distance_scrolled = distance
pipeline.scroll_helper.scroll_position = float(distance) pipeline.scroll_helper.scroll_position = float(distance)
@@ -523,7 +523,7 @@
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-group" id="setting-display-vegas_target_fps" data-setting-key="display.vegas_scroll.target_fps"> <div class="form-group" id="setting-display-vegas_target_fps" data-setting-key="display.vegas_scroll.target_fps">
<label for="vegas_target_fps" class="block text-sm font-medium text-gray-700">Target FPS{{ ui.help_tip('Frames per second the Vegas ticker aims to render.\nHigher = smoother scrolling but more CPU. Default: 125 (smoothest). Drop to 60/90 if the Pi runs hot.', 'Target FPS') }}</label> <label for="vegas_target_fps" class="block text-sm font-medium text-gray-700">Target FPS{{ ui.help_tip('Only used when Smooth motion is off; with it on, the panel refresh sets the frame rate.\nFrames per second the Vegas ticker aims to render. Default: 125.', 'Target FPS') }}</label>
<select id="vegas_target_fps" name="vegas_target_fps" class="form-control"> <select id="vegas_target_fps" name="vegas_target_fps" class="form-control">
<option value="60" {% if main_config.display.get('vegas_scroll', {}).get('target_fps', 125) == 60 %}selected{% endif %}>60 FPS (Lower CPU)</option> <option value="60" {% if main_config.display.get('vegas_scroll', {}).get('target_fps', 125) == 60 %}selected{% endif %}>60 FPS (Lower CPU)</option>
<option value="90" {% if main_config.display.get('vegas_scroll', {}).get('target_fps', 125) == 90 %}selected{% endif %}>90 FPS (Balanced)</option> <option value="90" {% if main_config.display.get('vegas_scroll', {}).get('target_fps', 125) == 90 %}selected{% endif %}>90 FPS (Balanced)</option>
@@ -564,7 +564,7 @@
name="vegas_smooth_scroll" name="vegas_smooth_scroll"
{% if main_config.display.get('vegas_scroll', {}).get('smooth_scroll', True) %}checked{% endif %} {% if main_config.display.get('vegas_scroll', {}).get('smooth_scroll', True) %}checked{% endif %}
class="form-checkbox"> class="form-checkbox">
<span class="ml-2 text-sm text-gray-700">Smooth sub-pixel motion{{ ui.help_tip('Blend between neighbouring pixel positions so the ticker moves once per rendered frame instead of once per pixel. Default: on.\nWithout it, motion happens only as often as the scroll speed in pixels per second — at 50 px/s that is 50 steps a second however fast the display renders, which reads as a slight judder. The trade is that text softens very slightly horizontally, since each frame blends two positions. Turn it off if you prefer maximum crispness.', 'Smooth Scrolling') }}</span> <span class="ml-2 text-sm text-gray-700">Smooth motion{{ ui.help_tip('Move the ticker a whole pixel at a time in step with the panel refresh, so every frame moves the same distance and text stays sharp. Default: on.\nThe scroll speed snaps to the nearest speed the panel can show this way: on a 100Hz panel, 100 px/s is one pixel every refresh and 50 px/s one pixel every second refresh. The real refresh rate is measured once scrolling starts, since a long chain often cannot reach its refresh cap.\nWith this off the ticker moves by elapsed time instead, which drifts against the refresh and judders.', 'Smooth Scrolling') }}</span>
</label> </label>
</div> </div>