fix(vegas): smooth Vegas scroll pacing -- whole pixels per refresh, measured refresh, off-thread preview writes (#628)

Vegas scrolls a whole number of pixels per panel refresh, locked to SwapOnVSync, against the refresh the panel really holds (measured from swap gaps), instead of blending sub-pixel positions against the refresh cap. The web preview PNG is encoded off the render thread while scrolling, with writes ordered and retried. On hdpi, late frames fell from 6.3% to 0.7%. See docs/SCROLL_PERFORMANCE.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-24 19:38:22 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent b9416ef803
commit 7f9c73e9aa
12 changed files with 632 additions and 71 deletions
+148
View File
@@ -353,3 +353,151 @@ class TestFrameHoldLifetime:
assert dm._frame_hold == 1
finally:
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_a_failed_background_write_is_retried_not_touched(
self, dm, tmp_path, monkeypatch):
# Queuing records the frame as written. If the writer then fails, an
# unchanged frame must be written again, not mtime-touched: touching
# would make a stale preview look healthy.
import threading
import time
failed = threading.Event()
def failing(image):
failed.set()
raise OSError("disk full")
monkeypatch.setattr(dm, "_save_snapshot", failing)
self._due(dm, tmp_path, (0, 255, 0))
dm.set_scrolling_state(True)
try:
dm.update_display()
assert failed.wait(5)
deadline = time.time() + 5
while dm._last_snapshot_digest is not None and time.time() < deadline:
time.sleep(0.01)
finally:
dm.set_scrolling_state(False)
assert dm._last_snapshot_digest is None
def test_a_frame_not_yet_on_disk_is_written_not_touched(
self, dm, tmp_path, monkeypatch):
# The digest is recorded when a frame is queued. Until the writer has
# saved it, an unchanged frame must not mtime-touch the older file on
# disk into looking current.
import zlib
from src.common import snapshot_policy
touched, saved = [], []
self._due(dm, tmp_path, (9, 9, 9))
digest = zlib.adler32(dm.image.tobytes())
dm._last_snapshot_digest = digest # queued earlier...
dm._saved_snapshot_digest = 12345 # ...but an older frame is on disk
monkeypatch.setattr(snapshot_policy, "decide",
lambda *a, **k: snapshot_policy.SnapshotAction.TOUCH)
monkeypatch.setattr(os, "utime", lambda *a, **k: touched.append(a))
monkeypatch.setattr(dm, "_save_snapshot", lambda image: saved.append(image))
dm.set_scrolling_state(False)
dm._write_snapshot_if_due(digest)
assert touched == []
assert len(saved) == 1
assert dm._saved_snapshot_digest == digest
# Once it is on disk, the same frame is only touched.
dm._write_snapshot_if_due(digest)
assert len(touched) == 1 and len(saved) == 1
def test_a_static_frame_lands_after_a_queued_one_still_being_written(
self, dm, tmp_path, monkeypatch):
# The last frame of a scroll can still be encoding when the first
# static frame is due; the older one must not land on top.
import threading
written, started, release = [], threading.Event(), threading.Event()
real = dm._save_snapshot
def slow_then_record(image):
if threading.current_thread().name == "snapshot-writer":
started.set()
release.wait(5)
written.append((threading.current_thread().name, image.getpixel((0, 0))))
real(image)
monkeypatch.setattr(dm, "_save_snapshot", slow_then_record)
self._due(dm, tmp_path, (0, 0, 255))
dm.set_scrolling_state(True)
dm.update_display() # queued: the writer blocks mid-write
assert started.wait(5)
dm.set_scrolling_state(False)
self._due(dm, tmp_path, (255, 0, 0))
static = threading.Thread(target=dm.update_display)
static.start()
static.join(0.2)
assert static.is_alive(), "the static save must wait for the write in flight"
release.set()
static.join(5)
assert [colour for _, colour in written] == [(0, 0, 255), (255, 0, 0)]
def test_cleanup_stops_the_writer(self, dm, tmp_path, monkeypatch):
threads, done = self._record_saves(dm, monkeypatch)
self._due(dm, tmp_path, (0, 255, 255))
dm.set_scrolling_state(True)
dm.update_display()
assert done.wait(5)
writer = dm._snapshot_thread
dm.set_scrolling_state(False)
dm._stop_snapshot_writer()
writer.join(2)
assert not writer.is_alive()
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]