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 @@