From 16b566e14fe7280019326d64c224ece29b4d2744 Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Thu, 1 Oct 2026 14:15:22 -0400 Subject: [PATCH] feat(scroll): show which scroll speeds are smooth on this panel (#710) * 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 * 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 * test: add the scroll-speed-advice route to the /api/v3 URL map snapshot Co-Authored-By: Claude Sonnet 5.5 --------- Co-authored-by: Claude Sonnet 5.5 --- CHANGELOG.md | 12 ++++ docs/SCROLL_PERFORMANCE.md | 4 ++ src/common/scroll_config.py | 68 ++++++++++++++++++- test/fixtures/api_v3_url_map.json | 9 +++ test/test_scroll_config.py | 39 +++++++++++ .../test_api_v3_scroll_speed_advice.py | 49 +++++++++++++ web_interface/blueprints/api_v3/config.py | 43 ++++++++++++ .../templates/v3/partials/display.html | 65 +++++++++++++++++- 8 files changed, 285 insertions(+), 4 deletions(-) create mode 100644 test/web_interface/test_api_v3_scroll_speed_advice.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bbdfece..f54edc55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -112,6 +112,18 @@ guard the import, since the loader's version check is advisory). processes, or turns the socket off with `off`. A non-root dev run uses a private per-user path under the temp directory. +### Scroll speed + +- The Vegas Scroll Speed slider now says what the panel will do with the speed + it is on, and offers the nearest smooth ones to click. Only speeds that advance + a whole number of pixels per refresh look smooth, and which those are depends + on the panel (`GET /api/v3/config/scroll-speed-advice`, built on + `scroll_config.speed_advice()`; it uses the refresh the display measured, not + the `limit_refresh_rate_hz` cap). The slider steps by 1 px/s instead of 5. +- The default 50 px/s no longer snaps to a stepped 48 px/s (2 px every 5 + refreshes, 24 fps) on a 120 Hz panel: `solve_crisp()` now prefers 60 or 40 px/s, + which move one pixel at a time. 100 Hz panels are unaffected. + ### Update channels - Devices no longer pick up every merge to `main`. A new setting, diff --git a/docs/SCROLL_PERFORMANCE.md b/docs/SCROLL_PERFORMANCE.md index 908d4685..92d23915 100644 --- a/docs/SCROLL_PERFORMANCE.md +++ b/docs/SCROLL_PERFORMANCE.md @@ -73,6 +73,10 @@ Sample ladder for a 100 Hz panel: 100.0 px/s (1px every 1 refresh = 100.0 fps, smooth) ``` +The Vegas **Scroll Speed** slider in the web UI shows the same thing live: a +line under it says what your speed will run as on this panel, and links to the +nearest smooth speeds. + ### How a slow speed stays crisp `SwapOnVSync(canvas, framerate_fraction)` holds each frame for N panel diff --git a/src/common/scroll_config.py b/src/common/scroll_config.py index 12cfb73e..f45acca1 100644 --- a/src/common/scroll_config.py +++ b/src/common/scroll_config.py @@ -157,7 +157,13 @@ def crisp_ladder( #: 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.10 # below 25fps +_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: @@ -173,7 +179,7 @@ def _quality_cost(candidate: "CrispSpeed", target: float) -> float: fps = candidate.frames_per_second if fps < 20: cost += _SLOW_FPS_PENALTY - elif fps < 25: + elif fps < 30: cost += _LOWISH_FPS_PENALTY return cost @@ -474,3 +480,61 @@ def refresh_hz_from_config(global_config: Optional[Dict[str, Any]]) -> float: 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], + } diff --git a/test/fixtures/api_v3_url_map.json b/test/fixtures/api_v3_url_map.json index 1e5a3779..52963d9c 100644 --- a/test/fixtures/api_v3_url_map.json +++ b/test/fixtures/api_v3_url_map.json @@ -192,6 +192,15 @@ "POST" ] ], + [ + "/api/v3/config/scroll-speed-advice", + "api_v3.get_scroll_speed_advice", + [ + "GET", + "HEAD", + "OPTIONS" + ] + ], [ "/api/v3/config/secrets", "api_v3.get_secrets_config", diff --git a/test/test_scroll_config.py b/test/test_scroll_config.py index ad0104a5..b3c143ca 100644 --- a/test/test_scroll_config.py +++ b/test/test_scroll_config.py @@ -13,6 +13,7 @@ from src.common.scroll_config import ( # noqa: E402 MAX_PIXELS_PER_FRAME, crisp_ladder, solve_crisp, + speed_advice, MAX_PIXELS_PER_SECOND, MIN_PIXELS_PER_SECOND, ScrollSettings, @@ -465,3 +466,41 @@ class TestFrameHoldIsReportedNotApplied: display_manager=dm) assert dm.calls == [], "configure() must not apply the hold itself" assert settings.frame_hold == 4, "but it must report what to apply" + + +class TestSpeedAdvice: + def test_default_speed_on_a_120hz_panel_is_not_left_stepped(self): + """50 px/s used to snap to 48 (2px every 5 refreshes, 24fps).""" + got = solve_crisp(50, 120) + assert got.steppiness == "smooth" + assert got.pixels_per_frame == 1 + + def test_unchanged_choices_on_a_100hz_panel(self): + assert solve_crisp(50, 100).pixels_per_second == pytest.approx(50.0) + assert solve_crisp(60, 100).pixels_per_second == pytest.approx(66.667, abs=0.01) + + def test_smooth_exact_speed_needs_no_alternatives(self): + advice = speed_advice(60, 120) + assert advice["exact"] and advice["smooth"] + assert advice["alternatives"] == [] + + def test_off_ladder_speed_offers_the_nearest_smooth_ones(self): + advice = speed_advice(50, 120, 10, 200) + assert advice["applied"]["steppiness"] == "smooth" + offered = [a["pixels_per_second"] for a in advice["alternatives"]] + assert offered == [40.0, 60.0] + assert all(a["steppiness"] == "smooth" for a in advice["alternatives"]) + + def test_alternatives_stay_inside_the_requested_range(self): + advice = speed_advice(50, 120, 45, 200) + assert all(45 <= a["pixels_per_second"] <= 200 for a in advice["alternatives"]) + + def test_a_whole_number_near_the_panels_speed_counts_as_exact(self): + """The UI sends 63 for a 62.9 px/s panel.""" + assert speed_advice(63, 125.74)["exact"] + + def test_a_measured_rate_does_not_let_a_stepped_speed_through(self): + """125.74Hz: 50.3px/s is 2px every 5 refreshes at 25.1fps.""" + got = solve_crisp(50, 125.74) + assert got.steppiness == "smooth" + assert got.pixels_per_frame == 1 diff --git a/test/web_interface/test_api_v3_scroll_speed_advice.py b/test/web_interface/test_api_v3_scroll_speed_advice.py new file mode 100644 index 00000000..270b9eda --- /dev/null +++ b/test/web_interface/test_api_v3_scroll_speed_advice.py @@ -0,0 +1,49 @@ +"""GET /api/v3/config/scroll-speed-advice: what the panel does with a speed.""" +import json +from unittest.mock import MagicMock + +import pytest +from flask import Flask + +from web_interface.blueprints.api_v3 import api_v3 +from web_interface.blueprints.api_v3 import config as config_routes + + +@pytest.fixture +def client(monkeypatch, tmp_path): + stats = tmp_path / "stats.json" + monkeypatch.setattr("src.common.frame_timing.default_stats_path", lambda: str(stats)) + manager = MagicMock() + manager.load_config.return_value = { + "display": {"hardware": {"limit_refresh_rate_hz": 120}}} + monkeypatch.setattr(api_v3, "config_manager", manager, raising=False) + app = Flask(__name__) + app.register_blueprint(api_v3, url_prefix="/api/v3") + c = app.test_client() + c.stats_path = stats + return c + + +def test_uses_the_configured_cap_without_a_measurement(client): + body = client.get("/api/v3/config/scroll-speed-advice?speed=50").get_json() + data = body["data"] + assert data["refresh_source"] == "configured" + assert data["refresh_hz"] == 120.0 + assert [a["pixels_per_second"] for a in data["alternatives"]] == [40.0, 60.0] + + +def test_prefers_the_measured_refresh(client): + client.stats_path.write_text(json.dumps({"measured_refresh_hz": 125.74})) + data = client.get("/api/v3/config/scroll-speed-advice?speed=50").get_json()["data"] + assert data["refresh_source"] == "measured" + assert data["refresh_hz"] == 125.7 + + +def test_ignores_an_implausible_stale_measurement(client): + client.stats_path.write_text(json.dumps({"measured_refresh_hz": 40.0})) + data = client.get("/api/v3/config/scroll-speed-advice?speed=50").get_json()["data"] + assert data["refresh_source"] == "configured" + + +def test_rejects_a_non_numeric_speed(client): + assert client.get("/api/v3/config/scroll-speed-advice?speed=fast").status_code == 400 diff --git a/web_interface/blueprints/api_v3/config.py b/web_interface/blueprints/api_v3/config.py index 9f50891d..1c278a3f 100644 --- a/web_interface/blueprints/api_v3/config.py +++ b/web_interface/blueprints/api_v3/config.py @@ -84,6 +84,49 @@ def get_main_config(): # any of it; /api/v3/auth/* manages it. return jsonify({'status': 'success', 'data': _redact_credentials(strip_auth_section(config))}) +def _panel_refresh_hz(config): + """(hz, source): the rate the panel really refreshes at, else the cap. + + The display service writes what it measured to the frame-stats file. The + configured limit_refresh_rate_hz is only a cap (a 120Hz cap refreshes at + ~126Hz on one rig, ~95Hz on a long chain), and the speeds that look smooth + are fractions of the real rate, so advice built on the cap can be wrong. + """ + from src.common import frame_timing, scroll_config + cap = scroll_config.refresh_hz_from_config(config) + try: + with open(frame_timing.default_stats_path(), encoding='utf-8') as fh: + measured = float(json.load(fh).get('measured_refresh_hz') or 0) + except (OSError, ValueError, TypeError, AttributeError): + measured = 0.0 + # Reject a stale file from a previous hardware config: a measurement far + # off the cap says the config changed since it was written. + if measured > 0 and 0.5 * cap <= measured <= 1.5 * cap: + return measured, 'measured' + return cap, 'configured' + + +@api_v3.route('/config/scroll-speed-advice', methods=['GET']) +def get_scroll_speed_advice(): + """What this panel does with a requested scroll speed, and smooth options. + + Backs the hint under the Vegas Scroll Speed slider. + """ + from src.common import scroll_config + try: + speed = float(request.args.get('speed', '')) + lo = float(request.args.get('min', 10)) + hi = float(request.args.get('max', 200)) + except ValueError: + return jsonify({'status': 'error', 'message': 'speed must be a number'}), 400 + if not api_v3.config_manager: + return jsonify({'status': 'error', 'message': 'Config manager not initialized'}), 500 + hz, source = _panel_refresh_hz(api_v3.config_manager.load_config()) + advice = scroll_config.speed_advice(speed, hz, lo, hi) + advice['refresh_source'] = source + return jsonify({'status': 'success', 'data': advice}) + + @api_v3.route('/config/schedule', methods=['GET']) def get_schedule_config(): """Get current schedule configuration""" diff --git a/web_interface/templates/v3/partials/display.html b/web_interface/templates/v3/partials/display.html index a1da0217..bb3909b6 100644 --- a/web_interface/templates/v3/partials/display.html +++ b/web_interface/templates/v3/partials/display.html @@ -457,7 +457,7 @@
- +
{{ main_config.display.get('vegas_scroll', {}).get('scroll_speed', 50) }}
+

@@ -915,6 +916,10 @@ document.getElementById('brightness').addEventListener('input', function() { }); } + // Declared before first use: let is not hoisted usably. + let scrollHintTimer = null; + let scrollHintSeq = 0; + // Update scroll speed display const scrollSpeedSlider = document.getElementById('vegas_scroll_speed'); const scrollSpeedValue = document.getElementById('vegas_scroll_speed_value'); @@ -922,6 +927,62 @@ document.getElementById('brightness').addEventListener('input', function() { if (scrollSpeedSlider && scrollSpeedValue) { scrollSpeedSlider.addEventListener('input', function() { scrollSpeedValue.textContent = this.value; + refreshScrollSpeedHint(); + }); + refreshScrollSpeedHint(); + } + + // Tell the user what the panel will really do with this speed. Only + // speeds that advance a whole number of pixels per refresh look smooth, + // and which those are depends on the panel, so the server works it out. + function refreshScrollSpeedHint() { + clearTimeout(scrollHintTimer); + scrollHintTimer = setTimeout(function() { + const hint = document.getElementById('vegas_scroll_speed_hint'); + if (!hint) return; + const seq = ++scrollHintSeq; + const q = new URLSearchParams({ + speed: scrollSpeedSlider.value, + min: scrollSpeedSlider.min, + max: scrollSpeedSlider.max + }); + fetch('/api/v3/config/scroll-speed-advice?' + q) + .then(function(r) { return r.json(); }) + .then(function(body) { + if (seq !== scrollHintSeq || body.status !== 'success') return; + renderScrollSpeedHint(hint, body.data); + }) + .catch(function() { hint.textContent = ''; }); + }, 150); + } + + function renderScrollSpeedHint(hint, a) { + const ap = a.applied; + const motion = ap.pixels_per_frame + ' px every ' + ap.frame_hold + + ' refresh' + (ap.frame_hold === 1 ? '' : 'es'); + hint.textContent = ''; + hint.className = 'mt-1 text-xs ' + (a.smooth && a.exact ? 'text-green-700' : 'text-amber-700'); + const line = document.createElement('span'); + if (a.smooth && a.exact) { + line.textContent = 'Smooth on this panel (' + motion + ', ' + a.refresh_hz + ' Hz).'; + } else { + line.textContent = a.requested + ' px/s will run as ' + ap.pixels_per_second + + ' px/s (' + motion + ', ' + ap.steppiness + ') on this ' + a.refresh_hz + + ' Hz panel.' + (a.alternatives.length ? ' Smooth speeds: ' : ''); + } + hint.appendChild(line); + a.alternatives.forEach(function(alt, i) { + if (i > 0) hint.appendChild(document.createTextNode(' ')); + const value = Math.round(alt.pixels_per_second); + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'underline font-medium'; + btn.textContent = value + ' px/s'; + btn.addEventListener('click', function() { + scrollSpeedSlider.value = value; + scrollSpeedSlider.dispatchEvent(new Event('input', {bubbles: true})); + }); + hint.appendChild(btn); }); }