mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 14:25:08 +00:00
* feat(scroll): show which scroll speeds are smooth on this panel The Vegas Scroll Speed slider now says what the panel will do with the chosen speed and offers the nearest smooth ones to click. Backed by scroll_config.speed_advice() and GET /api/v3/config/scroll-speed-advice, which uses the refresh the display measured rather than the cap. Also stops the default 50 px/s snapping to a stepped 48 px/s (2px every 5 refreshes, 24fps) on a 120Hz panel: the low-fps penalty in solve_crisp() now loses to 60 or 40 px/s. 100Hz panels are unchanged. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * fix(scroll): hint threw before its timer variables existed; count 25-30fps as stepped The Vegas speed hint called refreshScrollSpeedHint() before the let declarations it uses, so it never rendered (found on ledpi). And the solver's low-fps penalty stopped at 25fps, which let a measured 125.7Hz panel keep a 25.1fps 2px-every-5-refreshes scroll. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * test: add the scroll-speed-advice route to the /api/v3 URL map snapshot Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
541 lines
23 KiB
Python
541 lines
23 KiB
Python
"""One place that turns plugin config into a configured ScrollHelper.
|
|
|
|
Five ticker plugins each hand-rolled this resolution (odds-ticker, news and
|
|
ledmatrix-leaderboard reference the deprecated ``scroll_pixels_per_second``
|
|
key 16-18 times apiece), and they disagreed in ways that were invisible until
|
|
someone watched the panel:
|
|
|
|
* odds-ticker read ``scroll_pixels_per_second`` on the *recommended* config
|
|
path and let it override ``scroll_speed``/``scroll_delay``. Because that key
|
|
carries a schema default, the documented settings were dead for every user
|
|
-- see ChuckBuilds/ledmatrix-plugins#408.
|
|
* ledmatrix-leaderboard read the same key only as a fallback, so identical
|
|
config produced different speeds in the two plugins.
|
|
* stock-news derived px/frame from it via its own arithmetic.
|
|
|
|
What matters on the hardware
|
|
----------------------------
|
|
Motion is smooth when the strip advances a **whole number of pixels per panel
|
|
refresh**. On a 100Hz panel that means 100 px/s, 200 px/s, and so on. Anything
|
|
else has to either blend adjacent columns (which on pixel-font text reads as
|
|
shimmer) or repeat frames (which reads as judder). :func:`resolve` warns when
|
|
the requested speed will not divide evenly, because that is a real display
|
|
artefact and not a rounding detail.
|
|
|
|
How the speed is applied
|
|
------------------------
|
|
:func:`configure` snaps the requested speed to a :class:`CrispSpeed` -- a whole
|
|
number of pixels per presented frame, with each frame held for ``frame_hold``
|
|
panel refreshes -- and puts the helper in fixed-step mode
|
|
(``ScrollHelper.set_pixels_per_frame``). In that mode the helper consults no
|
|
clock: every ``update_scroll_position()`` call moves exactly that many pixels.
|
|
``SwapOnVSync`` blocks until the panel has taken each frame, so the frame count
|
|
is the clock, and the speed on the panel is::
|
|
|
|
px/s = refresh_hz / frame_hold * pixels_per_frame
|
|
|
|
That makes the hold part of the speed. The caller must pass
|
|
``settings.frame_hold`` to ``display_manager.set_scrolling_state(True, ...)``;
|
|
a caller that omits it is presented every refresh and scrolls ``frame_hold``
|
|
times too fast. The refresh is the panel's (``display_manager.refresh_hz``,
|
|
i.e. ``display.hardware.limit_refresh_rate_hz``) -- never the global
|
|
``target_fps``, which no longer paces anything.
|
|
|
|
With ``snap_to_crisp=False`` there is no fixed step: the helper is set to
|
|
px/s and advances by elapsed time, and the hold is 1.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from dataclasses import dataclass, replace
|
|
from typing import Any, Dict, List, Optional, cast
|
|
|
|
from src.matrix_support import DEFAULT_REFRESH_LIMIT_HZ
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
#: Speed used when a plugin supplies nothing usable. One pixel per refresh on a
|
|
#: 100Hz panel, which is the slowest crisp scroll that hardware can show.
|
|
DEFAULT_PIXELS_PER_SECOND = 100.0
|
|
|
|
#: Bounds accepted from config. Below the floor a marquee appears frozen;
|
|
#: above the ceiling it outruns any panel's refresh and tears.
|
|
MIN_PIXELS_PER_SECOND = 1.0
|
|
MAX_PIXELS_PER_SECOND = 500.0
|
|
|
|
#: Assumed refresh when the caller does not say. Matches the usual
|
|
#: ``display.hardware.limit_refresh_rate_hz``, and is the cap DisplayManager
|
|
#: applies when that key is missing.
|
|
DEFAULT_REFRESH_HZ = float(DEFAULT_REFRESH_LIMIT_HZ)
|
|
|
|
#: How far px/s may sit from a whole number of pixels per refresh before it is
|
|
#: worth warning about. 0.05px per frame is invisible; a third of a pixel is not.
|
|
_WHOLE_PIXEL_TOLERANCE = 0.05
|
|
|
|
|
|
#: Longest a frame may be held before motion reads as a slideshow rather than
|
|
#: a scroll. 6 refreshes at 100Hz is ~17px/s, already visibly stepped.
|
|
MAX_FRAME_HOLD = 8
|
|
|
|
#: Largest whole-pixel jump per presented frame before motion looks like it is
|
|
#: teleporting rather than sliding.
|
|
MAX_PIXELS_PER_FRAME = 6
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class CrispSpeed:
|
|
"""A speed the panel can show with whole-pixel motion.
|
|
|
|
``pixels_per_second`` is always ``refresh_hz / frame_hold * pixels_per_frame``
|
|
exactly -- no rounding, no fractional pixel positions, so nothing has to be
|
|
blended or repeated unevenly.
|
|
|
|
:param frame_hold: refreshes each frame is held for. This is rgbmatrix's
|
|
``SwapOnVSync(canvas, framerate_fraction)``. The panel keeps refreshing
|
|
at full rate either way, so holding a frame costs nothing in flicker.
|
|
:param pixels_per_frame: whole pixels advanced per presented frame.
|
|
"""
|
|
|
|
pixels_per_second: float
|
|
frame_hold: int
|
|
pixels_per_frame: int
|
|
refresh_hz: float
|
|
|
|
@property
|
|
def frames_per_second(self) -> float:
|
|
"""Distinct frames per second: the refresh divided by the hold."""
|
|
return self.refresh_hz / self.frame_hold
|
|
|
|
@property
|
|
def steppiness(self) -> str:
|
|
"""Rough readability hint for this combination."""
|
|
if self.pixels_per_frame > 2:
|
|
return "jumpy"
|
|
if self.frames_per_second < 20:
|
|
return "stepped"
|
|
if self.frames_per_second < 30:
|
|
return "slightly stepped"
|
|
return "smooth"
|
|
|
|
def describe(self) -> str:
|
|
"""This speed as a line for the speed ladder, aligned for a column."""
|
|
return (
|
|
f"{self.pixels_per_second:6.1f} px/s "
|
|
f"({self.pixels_per_frame}px every {self.frame_hold} refresh"
|
|
f"{'es' if self.frame_hold != 1 else ' '} = "
|
|
f"{self.frames_per_second:5.1f} fps, {self.steppiness})"
|
|
)
|
|
|
|
|
|
def crisp_ladder(
|
|
refresh_hz: float = DEFAULT_REFRESH_HZ,
|
|
max_frame_hold: int = MAX_FRAME_HOLD,
|
|
max_pixels_per_frame: int = MAX_PIXELS_PER_FRAME,
|
|
) -> List[CrispSpeed]:
|
|
"""Every whole-pixel speed this panel can show, slowest first.
|
|
|
|
Duplicates are collapsed keeping the gentlest option: 100 px/s is reachable
|
|
as 1px every refresh or 2px every 2nd refresh, and the former moves in
|
|
smaller increments, so that is the one worth offering.
|
|
"""
|
|
best: Dict[float, CrispSpeed] = {}
|
|
for hold in range(1, max_frame_hold + 1):
|
|
for ppf in range(1, max_pixels_per_frame + 1):
|
|
pps = refresh_hz / hold * ppf
|
|
key = round(pps, 3)
|
|
candidate = CrispSpeed(pps, hold, ppf, refresh_hz)
|
|
incumbent = best.get(key)
|
|
if incumbent is None or ppf < incumbent.pixels_per_frame:
|
|
best[key] = candidate
|
|
return [best[k] for k in sorted(best)]
|
|
|
|
|
|
#: How much a bigger pixel step costs, as a fraction of the target speed.
|
|
#: Tuned so 66.7px/s (2px at 33fps) beats 50px/s (1px at 50fps) when 60 was
|
|
#: asked for, but 33.3px/s (1px, smooth) still beats 28.6px/s (2px at 14fps)
|
|
#: when 30 was asked for -- being 11% slow is worth far less than looking bad.
|
|
_STEP_PENALTY = 0.05
|
|
_SLOW_FPS_PENALTY = 0.25 # below 20fps
|
|
_LOWISH_FPS_PENALTY = 0.16 # below 30fps, i.e. "slightly stepped"
|
|
# Up to 30fps, matching CrispSpeed.steppiness: a measured 125.7Hz panel makes
|
|
# 50.3px/s (2px every 5 refreshes) 25.1fps, which a 25fps cutoff let through.
|
|
# 0.16, not less: asked for 50px/s on a 120Hz panel, 48px/s (2px every 5
|
|
# refreshes, 24fps) costs 0.04 + 0.05 + this, and has to lose to both 60px/s
|
|
# and 40px/s (1px, smooth, 20% off = 0.20). At 0.10 it won and shipped a
|
|
# visibly stepped scroll to anyone asking for the default.
|
|
|
|
|
|
def _quality_cost(candidate: "CrispSpeed", target: float) -> float:
|
|
"""Lower is better. Numeric closeness alone picks bad-looking speeds.
|
|
|
|
Nearest-by-value would answer "30 px/s" with 28.6 px/s -- which is 2px
|
|
jumps at 14fps -- over 33.3 px/s, which is single-pixel motion at 33fps and
|
|
obviously better on the panel. Proximity has to be traded against how the
|
|
motion actually reads.
|
|
"""
|
|
error = abs(candidate.pixels_per_second - target) / max(target, 1e-6)
|
|
cost = error + _STEP_PENALTY * (candidate.pixels_per_frame - 1)
|
|
fps = candidate.frames_per_second
|
|
if fps < 20:
|
|
cost += _SLOW_FPS_PENALTY
|
|
elif fps < 30:
|
|
cost += _LOWISH_FPS_PENALTY
|
|
return cost
|
|
|
|
|
|
def solve_crisp(
|
|
target_pixels_per_second: float,
|
|
refresh_hz: float = DEFAULT_REFRESH_HZ,
|
|
max_frame_hold: int = MAX_FRAME_HOLD,
|
|
max_pixels_per_frame: int = MAX_PIXELS_PER_FRAME,
|
|
) -> CrispSpeed:
|
|
"""The whole-pixel speed that will look best for what was asked for.
|
|
|
|
Not simply the nearest -- see :func:`_quality_cost`. Ties break toward the
|
|
smaller pixel step and the shorter hold.
|
|
"""
|
|
ladder = crisp_ladder(refresh_hz, max_frame_hold, max_pixels_per_frame)
|
|
# Clamp into the ladder's range first. Relative error saturates near 1.0
|
|
# for a target far outside it, so the quality penalty would dominate and
|
|
# answer "10000 px/s" with the *slowest* entry -- smooth, and useless.
|
|
target = min(max(target_pixels_per_second, ladder[0].pixels_per_second),
|
|
ladder[-1].pixels_per_second)
|
|
return min(
|
|
ladder,
|
|
key=lambda c: (round(_quality_cost(c, target), 6),
|
|
c.pixels_per_frame, c.frame_hold),
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ScrollSettings:
|
|
"""The resolved outcome, and which config key produced it."""
|
|
|
|
pixels_per_second: float
|
|
source: str
|
|
target_fps: Optional[float] = None
|
|
pixels_per_frame: Optional[float] = None
|
|
warning: Optional[str] = None
|
|
#: The whole-pixel speed actually applied, when snapping was enabled.
|
|
crisp: Optional[CrispSpeed] = None
|
|
#: What the config asked for, before snapping.
|
|
requested_pixels_per_second: Optional[float] = None
|
|
|
|
@property
|
|
def frame_hold(self) -> int:
|
|
"""Refreshes to hold each frame for; pass to set_scrolling_state()."""
|
|
return self.crisp.frame_hold if self.crisp else 1
|
|
|
|
def describe(self) -> str:
|
|
"""One log line: the speed applied, and which config key produced it."""
|
|
text = f"{self.pixels_per_second:.1f} px/s (from {self.source})"
|
|
if self.pixels_per_frame is not None:
|
|
text += f" = {self.pixels_per_frame:.2f} px/frame"
|
|
if self.target_fps:
|
|
text += f" at {self.target_fps:.0f} fps"
|
|
return text
|
|
|
|
|
|
def _coerce(value: Any) -> Optional[float]:
|
|
"""A positive float, or None. Config reaches us with nulls and strings."""
|
|
if value is None or isinstance(value, bool):
|
|
return None
|
|
try:
|
|
number = float(value)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
return number if number > 0 else None
|
|
|
|
|
|
def _from_speed_and_delay(block: Any) -> Optional[float]:
|
|
"""px/s from a ``scroll_speed`` (px/frame) + ``scroll_delay`` (s) pair."""
|
|
if not isinstance(block, dict):
|
|
return None
|
|
speed = _coerce(block.get("scroll_speed"))
|
|
delay = _coerce(block.get("scroll_delay"))
|
|
if speed is None or delay is None:
|
|
return None
|
|
return speed / delay
|
|
|
|
|
|
def resolve(
|
|
plugin_config: Optional[Dict[str, Any]] = None,
|
|
global_config: Optional[Dict[str, Any]] = None,
|
|
default_pixels_per_second: float = DEFAULT_PIXELS_PER_SECOND,
|
|
refresh_hz: Optional[float] = None,
|
|
) -> ScrollSettings:
|
|
"""Resolve one scroll speed from the several shapes plugins accept.
|
|
|
|
Precedence, highest first. The deprecated flat key sits *below* the
|
|
explicit pairs deliberately: it carries schema defaults in some plugins, so
|
|
ranking it above them silently disables the documented settings.
|
|
|
|
1. ``display_options.scroll_speed`` + ``scroll_delay`` (current)
|
|
2. ``display.scroll_speed`` + ``scroll_delay`` (deprecated shape)
|
|
3. ``scroll_speed`` + ``scroll_delay`` at the root (legacy flat)
|
|
4. ``scroll_pixels_per_second``, nested or flat (deprecated)
|
|
5. the global ``display`` block
|
|
6. ``default_pixels_per_second``
|
|
|
|
:param refresh_hz: panel refresh, used only to check whether the resolved
|
|
speed lands on whole pixels per frame and to fill in ``target_fps``.
|
|
"""
|
|
plugin_config = plugin_config or {}
|
|
global_config = global_config or {}
|
|
refresh = _coerce(refresh_hz) or DEFAULT_REFRESH_HZ
|
|
|
|
display_options = plugin_config.get("display_options")
|
|
display_block = plugin_config.get("display")
|
|
|
|
candidates = [
|
|
(_from_speed_and_delay(display_options), "display_options.scroll_speed/delay"),
|
|
(_from_speed_and_delay(display_block), "display.scroll_speed/delay"),
|
|
(_from_speed_and_delay(plugin_config), "scroll_speed/delay (root)"),
|
|
]
|
|
for block, label in (
|
|
(display_options, "display_options.scroll_pixels_per_second"),
|
|
(display_block, "display.scroll_pixels_per_second"),
|
|
(plugin_config, "scroll_pixels_per_second"),
|
|
):
|
|
if isinstance(block, dict):
|
|
candidates.append((_coerce(block.get("scroll_pixels_per_second")), label))
|
|
|
|
global_display = global_config.get("display")
|
|
candidates.append((_from_speed_and_delay(global_display), "global display.scroll_speed/delay"))
|
|
|
|
pixels_per_second = None
|
|
source = "default"
|
|
for value, label in candidates:
|
|
if value is not None:
|
|
pixels_per_second, source = value, label
|
|
break
|
|
if pixels_per_second is None:
|
|
pixels_per_second = default_pixels_per_second
|
|
|
|
clamped = max(MIN_PIXELS_PER_SECOND, min(MAX_PIXELS_PER_SECOND, pixels_per_second))
|
|
warning = None
|
|
if clamped != pixels_per_second:
|
|
warning = (
|
|
f"scroll speed {pixels_per_second:.1f} px/s out of range, "
|
|
f"clamped to {clamped:.1f}"
|
|
)
|
|
pixels_per_second = clamped
|
|
|
|
pixels_per_frame = pixels_per_second / refresh if refresh > 0 else None
|
|
if warning is None and pixels_per_frame is not None:
|
|
offset = abs(pixels_per_frame - round(pixels_per_frame))
|
|
if pixels_per_frame < 1.0 - _WHOLE_PIXEL_TOLERANCE or offset > _WHOLE_PIXEL_TOLERANCE:
|
|
suggestion = max(1.0, round(pixels_per_frame)) * refresh
|
|
warning = (
|
|
f"{pixels_per_second:.1f} px/s is {pixels_per_frame:.2f} px per "
|
|
f"refresh at {refresh:.0f}Hz, so some frames repeat and the "
|
|
f"scroll will judder; {suggestion:.0f} px/s divides evenly"
|
|
)
|
|
|
|
return ScrollSettings(
|
|
pixels_per_second=pixels_per_second,
|
|
source=source,
|
|
target_fps=refresh,
|
|
pixels_per_frame=pixels_per_frame,
|
|
warning=warning,
|
|
)
|
|
|
|
|
|
def configure(
|
|
scroll_helper: Any,
|
|
plugin_config: Optional[Dict[str, Any]] = None,
|
|
global_config: Optional[Dict[str, Any]] = None,
|
|
default_pixels_per_second: float = DEFAULT_PIXELS_PER_SECOND,
|
|
refresh_hz: Optional[float] = None,
|
|
plugin_logger: Optional[logging.Logger] = None,
|
|
display_manager: Any = None,
|
|
snap_to_crisp: bool = True,
|
|
) -> ScrollSettings:
|
|
"""Resolve the config and apply it to ``scroll_helper``.
|
|
|
|
Frame-based mode is switched off, the (snapped) speed is set, and with
|
|
``snap_to_crisp`` the helper steps a fixed whole-pixel amount per presented
|
|
frame -- see the module docstring. ``hasattr`` guards keep this usable
|
|
against older ScrollHelper builds that a plugin may be running on.
|
|
|
|
:param display_manager: consulted for the panel's refresh rate only (it can
|
|
see display.hardware; a plugin cannot). The frame hold is NOT applied
|
|
here -- see the note in the body. The caller must pass
|
|
``settings.frame_hold`` to ``display_manager.set_scrolling_state(True,
|
|
...)`` when it scrolls. The helper moves its fixed step on every call,
|
|
so without the hold each step is presented every refresh and the
|
|
scroll runs ``frame_hold`` times faster than the resolved speed.
|
|
:param snap_to_crisp: move the requested speed to the nearest speed the
|
|
panel can show in whole pixels. On by default because a speed that does
|
|
not divide evenly has no good rendering, only a choice of artefacts.
|
|
|
|
:returns: the settings applied, so the caller can log or assert on them.
|
|
"""
|
|
log = plugin_logger or logger
|
|
|
|
# Refresh rate, most authoritative first: what the caller passed, then the
|
|
# display manager (which can see display.hardware; a plugin cannot), then
|
|
# the global config, then the default.
|
|
#
|
|
# This has to be settled BEFORE resolve(), not after. resolve() uses the
|
|
# refresh to fill in target_fps, pixels_per_frame and the judder warning,
|
|
# so deriving it afterwards described a 100Hz panel to everyone running at
|
|
# 60 -- and with snap_to_crisp=False nothing downstream corrected it, so
|
|
# the settings and the helper's (informational) target_fps said 100 FPS on
|
|
# a 60Hz panel.
|
|
hz = _coerce(refresh_hz)
|
|
if hz is None and display_manager is not None:
|
|
hz = _coerce(getattr(display_manager, "refresh_hz", None))
|
|
if hz is None:
|
|
hz = refresh_hz_from_config(global_config)
|
|
|
|
settings = resolve(
|
|
plugin_config,
|
|
global_config,
|
|
default_pixels_per_second=default_pixels_per_second,
|
|
refresh_hz=hz,
|
|
)
|
|
applied = settings.pixels_per_second
|
|
choice = None
|
|
|
|
if snap_to_crisp:
|
|
choice = solve_crisp(settings.pixels_per_second, hz)
|
|
applied = choice.pixels_per_second
|
|
settings = replace(
|
|
settings,
|
|
pixels_per_second=applied,
|
|
requested_pixels_per_second=settings.pixels_per_second,
|
|
crisp=choice,
|
|
pixels_per_frame=float(choice.pixels_per_frame),
|
|
# Snapping resolves the whole-pixel problem the warning describes.
|
|
warning=None if settings.warning and "judder" in settings.warning
|
|
else settings.warning,
|
|
)
|
|
|
|
if hasattr(scroll_helper, "set_frame_based_scrolling"):
|
|
scroll_helper.set_frame_based_scrolling(False)
|
|
scroll_helper.set_scroll_speed(applied)
|
|
|
|
# A crisp speed is a whole number of pixels per presented frame, so step by
|
|
# that number rather than by speed * elapsed time. Snapping alone only
|
|
# fixes the average: the wall clock puts the accumulator back on an integer
|
|
# boundary every frame, where jitter of a fraction of a millisecond decides
|
|
# whether the pixel moves. That is what the ladder was bought to prevent.
|
|
if hasattr(scroll_helper, "set_pixels_per_frame"):
|
|
scroll_helper.set_pixels_per_frame(
|
|
choice.pixels_per_frame if choice else None)
|
|
# Informational only: nothing in the helper paces off target_fps. It is
|
|
# still recorded because plugins read it back (ledmatrix-elections'
|
|
# test_scroll_pacing.py asserts it equals the crisp presentation rate).
|
|
if choice and hasattr(scroll_helper, "set_target_fps"):
|
|
scroll_helper.set_target_fps(choice.frames_per_second)
|
|
elif settings.target_fps and hasattr(scroll_helper, "set_target_fps"):
|
|
scroll_helper.set_target_fps(settings.target_fps)
|
|
|
|
# Deliberately NOT applied here. The hold belongs to a scroll, not to a
|
|
# plugin's lifetime: plugins share one display manager, and one left set at
|
|
# construction is reset the moment any other plugin finishes scrolling.
|
|
# Callers pass settings.frame_hold to set_scrolling_state(True, ...) when
|
|
# they start scrolling. configure() only reports what is needed.
|
|
|
|
if choice:
|
|
# Set whenever there is a crisp choice (see the replace() above).
|
|
requested = cast(float, settings.requested_pixels_per_second)
|
|
if abs(requested - applied) > 0.05:
|
|
log.info(
|
|
"Scroll configured: %s (asked for %.1f px/s from %s; "
|
|
"nearest whole-pixel speed on a %.0fHz panel)",
|
|
choice.describe(), requested, settings.source, hz,
|
|
)
|
|
else:
|
|
log.info("Scroll configured: %s (from %s)",
|
|
choice.describe(), settings.source)
|
|
if choice.frame_hold > 1:
|
|
log.debug(
|
|
"Scroll needs a frame hold of %d - pass settings.frame_hold to "
|
|
"display_manager.set_scrolling_state(True, ...) each scroll",
|
|
choice.frame_hold,
|
|
)
|
|
else:
|
|
log.info("Scroll configured: %s", settings.describe())
|
|
|
|
if settings.warning:
|
|
log.warning("Scroll speed: %s", settings.warning)
|
|
return settings
|
|
|
|
|
|
def refresh_hz_from_config(global_config: Optional[Dict[str, Any]]) -> float:
|
|
"""The panel's refresh cap from the global config, or the default."""
|
|
if not isinstance(global_config, dict):
|
|
return DEFAULT_REFRESH_HZ
|
|
# Each level is checked for being a mapping rather than merely truthy: a
|
|
# malformed config where display or display.hardware is a string or a list
|
|
# raised AttributeError out of what is meant to be a total function with a
|
|
# default, taking down every caller that asked for the refresh rate.
|
|
display = global_config.get("display")
|
|
if not isinstance(display, dict):
|
|
return DEFAULT_REFRESH_HZ
|
|
hardware = display.get("hardware")
|
|
if not isinstance(hardware, dict):
|
|
return DEFAULT_REFRESH_HZ
|
|
return _coerce(hardware.get("limit_refresh_rate_hz")) or DEFAULT_REFRESH_HZ
|
|
|
|
|
|
#: Smooth options offered next to a speed that is not one itself.
|
|
_ADVICE_ALTERNATIVES = 2
|
|
|
|
|
|
def speed_advice(
|
|
requested_pixels_per_second: float,
|
|
refresh_hz: float,
|
|
min_pixels_per_second: float = MIN_PIXELS_PER_SECOND,
|
|
max_pixels_per_second: float = MAX_PIXELS_PER_SECOND,
|
|
) -> Dict[str, Any]:
|
|
"""What the panel will do with a requested speed, for showing in a UI.
|
|
|
|
``applied`` is what :func:`solve_crisp` picks, i.e. what really runs.
|
|
``smooth`` is true when that is single-pixel-ish, 30fps-or-better motion.
|
|
``alternatives`` are the smooth ladder entries nearest the request inside
|
|
the given range, for a click-to-apply suggestion; empty when the request
|
|
already is one.
|
|
"""
|
|
hz = _coerce(refresh_hz) or DEFAULT_REFRESH_HZ
|
|
requested = max(MIN_PIXELS_PER_SECOND,
|
|
min(MAX_PIXELS_PER_SECOND, _coerce(requested_pixels_per_second) or 0.0))
|
|
applied = solve_crisp(requested, hz)
|
|
|
|
def as_dict(c: CrispSpeed) -> Dict[str, Any]:
|
|
return {
|
|
"pixels_per_second": round(c.pixels_per_second, 1),
|
|
"pixels_per_frame": c.pixels_per_frame,
|
|
"frame_hold": c.frame_hold,
|
|
"frames_per_second": round(c.frames_per_second, 1),
|
|
"steppiness": c.steppiness,
|
|
}
|
|
|
|
smooth_ladder = [
|
|
c for c in crisp_ladder(hz)
|
|
if c.steppiness == "smooth"
|
|
and min_pixels_per_second <= c.pixels_per_second <= max_pixels_per_second
|
|
]
|
|
# 2%: a UI hands over whole numbers, and 63 asked of a 62.9 px/s panel is
|
|
# as good as exact.
|
|
exact = abs(applied.pixels_per_second - requested) <= max(0.05, 0.02 * requested)
|
|
smooth = applied.steppiness == "smooth"
|
|
alternatives: List[CrispSpeed] = []
|
|
if not (exact and smooth):
|
|
alternatives = sorted(
|
|
smooth_ladder,
|
|
key=lambda c: abs(c.pixels_per_second - requested),
|
|
)[:_ADVICE_ALTERNATIVES]
|
|
alternatives.sort(key=lambda c: c.pixels_per_second)
|
|
return {
|
|
"requested": round(requested, 1),
|
|
"refresh_hz": round(hz, 1),
|
|
"applied": as_dict(applied),
|
|
"exact": exact,
|
|
"smooth": smooth,
|
|
"alternatives": [as_dict(c) for c in alternatives],
|
|
}
|