Compare commits

..
Author SHA1 Message Date
5b45f35888 Vegas mode: reclaim dead space and pace the rotation (#423)
* Vegas mode: reclaim dead space and pace the rotation

On a wide panel Vegas mode spent much of its time showing black. At 50px/s
on a 512px display, one display width of blank is 10.2 seconds, which makes
several long-standing behaviours expensive:

- ScrollHelper prepended a full display width of black as an "initial gap",
  charged once per cycle — 10.2s of black at the start of every rotation.
- Plugins without get_vegas_content() are captured off a full-display canvas,
  so their blank margins entered the ticker too. Measured: of-the-day drew
  35px of "No Data" on a 512px canvas (92% blank), youtube-stats 142px of
  content with 185px of black either side. Only the scroll_helper path had
  any trimming.
- Cycle transitions deliberately pushed a blank frame and then recomposed
  synchronously: 84ms at best, 4.8s at worst, every millisecond of it black.
- buffer_ahead doubled as the cycle size, so a 21-plugin install showed 3
  plugins per cycle and took ~7 cycles to come around.
- separator_width was applied between every image rather than at plugin
  boundaries, so a per-row ticker like the F1 scoreboard (116 images, which
  it renders 4px apart internally) got a 32px chasm between each row — and
  the width budget didn't count those gaps, so the plugin quietly occupied
  far more of the panel than intended.

Changes:

- src/vegas_mode/geometry.py: numpy column-ink primitives shared by the
  trimmer and the audit tool, so the number reported is the number acted on.
  A Python per-column loop over a 17,000px strip is far too slow for the
  render path.
- PluginAdapter trims every content path, not just scroll_helper. Only outer
  edges are cropped: interior blank columns are the plugin's own layout
  (logo left, score right) and closing them would corrupt the design. A
  plugin on a non-black background is inherently unaffected.
- ScrollHelper.create_scrolling_image takes an explicit lead_gap, still
  defaulting to display_width so the many standalone-ticker callers are
  unchanged. Vegas passes lead_in_width (default 0).
- Cycle end holds the last rendered frame instead of blanking, turning the
  recompose into a brief freeze rather than the panel switching off.
- plugins_per_cycle (default 6) is split from buffer_ahead, which goes back
  to being only a prefetch low-water mark.
- max_plugin_width_ratio (default 3x display width) caps one plugin's share
  of a cycle. Overflow is deferred, not discarded: a rotation offset advances
  each fetch so later rows appear on subsequent cycles. Single oversized
  images are cropped at a blank column so the cut misses glyphs.
- Composition groups images by plugin: rows are joined by intra_plugin_gap
  (default 8) and separator_width applies only between plugins. The width
  budget now counts those gaps.
- Plugin data updates no longer run on the Vegas render path.

All new settings are user-configurable in Display -> Vegas Scroll, including
min/max cycle duration and dynamic duration, which previously existed in code
but were reachable only by hand-editing config.json.

Measured with scripts/dev/vegas_audit.py on a 512x64 panel:

  mean ink coverage    42.7% -> 69.4%
  fully blank           5.9% -> 0%
  reads as empty        13.6% -> 0%
  worst blank stretch    4.8s -> 0s
  full rotation          414s -> 123s
  plugins per cycle         3 -> 6

Note the metric choice: a "fully blank" scan (>=95% black viewport) reported
only 0.4% and badly understated the problem, because two full-width segments
with mid-canvas content never fully blank the viewport — they hold it at ~28%.
window_coverage_stats grades every viewport position by how much ink it
carries, which is what tracks perceived dead time.

Known remaining: cycle transitions still freeze ~3.5s while the next cycle is
fetched. Fixing that needs background prefetch, which is deferred because the
fallback-capture path mutates the shared display_manager.image and racing it
against the render loop risks torn frames.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ

* Drop unused Optional import from the vegas audit script

Flagged by Codacy (F401). Any, Dict and List are all still used.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ

* Align Vegas API bounds with validate(), fix audit config plumbing

Both from review feedback on #423.

The web API's accepted ranges disagreed with VegasModeConfig.validate(),
which is what actually gates Vegas starting:

  scroll_speed      1-100  -> 1-200   (a slider value of 150 returned 400)
  separator_width   0-500  -> 0-128
  target_fps        1-200  -> 30-200
  buffer_ahead      1-20   -> 1-5

The three loose ones were the dangerous direction: the value saved with a
200, then VegasModeCoordinator.start() failed validation with only a log
line, so the ticker silently never ran. The UI already matched validate() in
all four cases, so the API was the odd one out.

test_vegas_api_bounds_match_validate parses the numeric_fields map out of
api_v3 and asserts every bound against validate(), plus that validate()
accepts both endpoints and rejects just outside them, so these cannot drift
apart again. That test immediately caught a missing upper bound on
min_plugin_width, now added — unbounded it would drop every segment and
leave a blank ticker.

Separately, vegas_audit.py constructed PluginAdapter without the config, so
it fell back to VegasModeConfig() defaults and would report trimming and
width-budget behaviour that differed from the user's config.json. It now
passes the loaded config exactly as the coordinator does. This is the same
class of drift the explicit lead_gap and grouping arguments already guard
against. Output is unchanged on a rig whose config matches the defaults.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ

* Vegas mode: render plugins narrower, space rows by measured separation

Trimming reclaims blank margins but cannot compact a layout that genuinely
spans the display — a five-column forecast, a progress bar drawn at 100%
width, a stat block with the panel's whole width between its elements. Those
need the plugin to make different layout decisions, which means telling it the
screen is narrower while it renders.

DisplayManager.render_size() presents a smaller logical canvas for the
duration of a Vegas content fetch, reusing the same _LogicalMatrix
indirection double-sided mode already relies on so plugins see a consistent
size from every accessor. Plugins that size themselves from matrix.width need
no changes at all; one that wants to be explicit can read the new
BasePlugin.get_vegas_render_width().

Width is a percentage so a single setting travels across panel sizes:
vegas_scroll.render_width_pct globally, or vegas_width_pct in an individual
plugin's config. Measured on a 512x64 panel with real data:

  ledmatrix-weather   1536px -> 576px   (forecast becomes narrow cards)
  youtube-stats        353px -> 199px   (2% blank left, so genuinely compact)
  geochron             453px -> 153px   (ink density rises to 100%)
  ledmatrix-flights    950px -> 740px

The youtube-stats figure is the clearest evidence the layout itself changed
rather than being cropped: at full width the content had to be trimmed from
512px to 353px, whereas at 40% it arrives with almost no blank to reclaim.

Row spacing is now measured rather than added. A flat gap gets it wrong in
both directions at once — content drawn flush to its own edges ends up nearly
touching (reported for recent sports scores, which sat 8px apart), while
content already carrying wide margins gets pushed even further out.
separation_gap() measures the blank each pair already has and adds only the
shortfall, up to min_content_separation (default 24). intra_plugin_gap stays
as a floor applied regardless.

Two tests shipped in the previous commit encoded the old flat-gap arithmetic
and are updated to the measured semantics, including one renamed to reflect
that zero intra_plugin_gap alone no longer butts rows together.

Also fixes a real bug found while testing: the harness display manager had no
render_size(), and because the adapter catches broadly that surfaced as "no
content" rather than an error, silently dropping five plugins. Added the
context to VisualTestDisplayManager for parity, and _render_at() now degrades
to a no-op on any display manager lacking it, so a third-party or older
harness loses the narrowing rather than the content.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ

* Vegas mode: end cycles before the wrap, keep the width budget honest

Three fixes, the first a regression from lead_in_width defaulting to 0.

get_visible_portion wraps: once scroll_position + display_width passes the end
of the strip it fills the right of the frame from the *head* of the same strip.
So the final display_width of travel showed the cycle's first plugin re-entering
on the right while its last plugin exited on the left, and the recompose that
followed replaced both at once. On a 512px panel at 50px/s that was 10.2s of
two plugins on screen at once, ending in a hard cut — reported as the ticker
"switching mid-scroll" from F1 to news.

That used to be invisible because the strip began with a full display_width of
blank, so the wrapped-in region was black. Removing that blank (it was 10s of
dead panel per cycle) exposed the wrap. Cycles now end one display width
earlier, before any wrapped content appears, clamped for strips no wider than
the display so they don't complete instantly and spin the recompose loop.

Verified on hardware: a 3936px strip now completes at 68.5s, exactly
(3936 - 512) / 50.

Second, auto_trim=False also skipped the width budget, which is an unrelated
concern — turning off margin cropping should not let one plugin hold the panel
for minutes. Seen in the field: the F1 scoreboard contributed 116 images and
14,848px untouched, giving a 33,821px cycle (11 minutes of content). The budget
now applies regardless of trimming; with it restored that cycle is 6,362px.

Third, the budget accounted for row gaps using the flat intra_plugin_gap while
the compositor had moved to measured separation, so it under-counted by up to
(min_content_separation - intra_plugin_gap) per row and a many-row plugin
overran its cap. Both now use the same separation_gap() rule, and a test
asserts the composed block fits the budget end to end rather than trusting the
two paths to agree.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ

* Fix IndexError in find_blank_cut when the cut lands on the image edge

A cut position after the last column is legitimate — _crop_to_budget asks for
min(start + budget, img.width), which equals the width whenever the remaining
strip is shorter than the budget. find_blank_cut clamped target to width but
then walked leftwards starting at target itself, so ink[width] raised
IndexError.

Caught on hardware: it killed the ledmatrix-stocks fetch, and because
_fetch_plugin_content catches broadly that surfaced as the plugin silently
contributing nothing for the cycle.

Only reachable on the second or later pass of the rotating window over a single
oversized image, which is why the existing tests missed it — they all exercised
the first pass, where start is 0 and start + budget is comfortably inside the
image. Added TestRotationAcrossMultipleCycles, which walks the window round
several times and asserts content is never lost, plus direct coverage of
find_blank_cut at and beyond the image edge.

Both bounds now stop at width - 1 so neither direction can index past the end.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ

* Only cut oversized segments at real gaps between items

The width-budget crop snapped to the nearest blank column, and in rendered text
the gap between two characters is a single column. So a cut routinely landed
inside a word: the cycle showed "Wednesda" and the orphaned "y" turned up as a
lone floating letter in the next cycle, positioned after whatever plugin
happened to precede it.

Measured on the clock-simple segment to confirm: its blank runs are
[1, 1, 1, 1, 1, 8, 8] — five single-column letter gaps, every one of which
find_blank_cut would happily have chosen.

Cuts now only land in a run of at least min_cut_gap blank columns (default 6),
which excludes letter spacing while still finding the gaps plugins put between
items (the stocks ticker uses 32px, baseball 48px). Where no boundary falls
inside the budget the cut waits for the next one and overruns, because
splitting an item is worse than a slightly long segment.

Continuous content is treated differently on purpose: an image with no internal
gaps is a map or a chart, where any column is as good as another, so it is still
cut to the budget exactly. The gap rule protects discrete items; letting a solid
image escape the cap in its name would be wrong.

blank_runs() is vectorised — 48ms for a 17,000px strip, against seconds for a
per-column Python loop.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ

* Hold capture_mode for every plugin render, not just narrowed ones

The native content path only entered capture_mode when it was also narrowing
the canvas, so at full width — which is every plugin without a vegas_width_pct
override, i.e. most of them — a plugin calling update_display() while building
its Vegas content wrote straight to the hardware. That is a visible flash
mid-scroll, and it lines up with the flash reported at cycle transitions, when
several plugins are fetched back to back.

Suppression is now unconditional; the narrowing context stays separate because
it is already a no-op at full width.

Both contexts are reached through helpers that degrade to nullcontext when the
display manager lacks them. That matters more than it looks: the adapter's
handlers are deliberately broad, so an AttributeError from a missing context
does not surface as an error — it surfaces as the plugin contributing nothing.
Making the call unconditional without this turned 44 tests red for exactly that
reason, all of them reporting lost content rather than the real cause.

The test double now provides capture_mode and render_size too, so tests
exercise the real contexts instead of silently taking the degraded path.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ

* Vegas mode: one continuous strip instead of swapping cycles

A cycle used to be a discrete strip that got replaced: motion stopped, every
pixel was substituted at once, and the next group started with the viewport
already full. That is the freeze, the flash and the jump.

The strip is now extended rather than replaced. ScrollHelper gains
append_content(), which adds items on the right without touching
scroll_position or total_distance_scrolled, so motion continues and the next
group simply arrives from the right. Because completion is measured against
total_scroll_width, extending also defers completion — there is no longer a
cycle boundary to see.

drop_scrolled_prefix() reclaims what has gone past, keeping the strip bounded
however long Vegas runs (observed 5,000-11,000px against an unbounded strip
otherwise). It shifts total_distance_scrolled and total_scroll_width together so
the completion arithmetic is unchanged, and refuses to run while the viewport is
wrapping: wrapping reads the head of the strip into the right of the frame, so
trimming the head there would visibly change the picture. A test caught that.

Groups are prepared off the render thread. The constraint is that the canvas and
the matrix proxy are process-wide mutable state, so narrowing or capturing
through them from another thread would corrupt the frame the render loop is
pushing. get_content() therefore takes offscreen_only: the background thread uses
only paths that avoid the canvas, and anything needing it is marked and picked up
on the render thread. That puts the expensive work (native renders of leaderboard
and baseball cards, seconds each) in the background and leaves the cheap work
(display capture, 40-600ms) in the foreground.

DisplayManager's capture flag is now thread-local. As a shared flag, a background
capture would have suppressed the render loop's own frame pushes for its
duration, freezing the panel precisely when the point was to avoid a freeze.

Canvas-bound plugins are drained one at a time rather than as a batch: six at
once held the render thread for 1.75s. Drains are also spaced by two seconds
while the lookahead is healthy, since taking them back to back turns one long
stall into a run of short ones. When the strip is genuinely running short the
throttle is ignored, because content matters more than smoothness there.

Measured on hardware: zero cycle-complete swaps, drains landing 2-4s apart,
lookahead holding at 1,200-3,500px, no errors.

Set continuous_scroll false to restore the swap behaviour; the old path is intact.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ

* Pace the Vegas frame loop adaptively: 31.5 -> 78.7 fps

The loop slept a fixed frame_interval on top of however long the frame took, so
at a measured 31.6ms per frame a flat 8ms of that was pure idle — a quarter of
the budget spent not rendering. It now sleeps only the remainder of the budget.

Measured on hardware: 31.5 fps to 78.7 fps sustained, with CPU going *down* from
150% to 127%. Scroll speed is unchanged at 49.9px/s against a configured 50,
because motion is derived from elapsed time rather than frame count — this buys
smoothness, not speed.

Worth recording what the bottleneck was not: the per-frame render path measures
0.34ms in total (0.18ms for the numpy slice, 0.17ms for the dirty-tracking
digest), which is a theoretical 2900 fps. Optimising any of that would have been
wasted effort. The frame was idle, not busy.

Also nices the prefetch thread. Its work is PIL and numpy that releases the GIL,
so the scheduler can act on the priority, and without it the prefetch competes
for the same cores as the render loop.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ

* Sub-pixel scrolling: motion at the frame rate, not the pixel rate

With integer positioning the number of distinct frames per second equals the
scroll speed in px/s, however fast the loop renders. Measured at 50px/s and
78.7fps, 36% of frames were byte-identical: the extra frames cost work and
bought no motion, and what was left was 50 discrete 1px steps a second.

Two things were wrong with the pre-existing sub-pixel support. get_visible_portion
never consulted sub_pixel_scrolling — it always took the integer path, so the flag
and _get_visible_portion_subpixel were dead code. And that implementation needed
scipy.ndimage.shift, which is not installed on the target devices (HAS_SCIPY is
False there), so it would not have interpolated even if reached. Verified both:
positions 1000.0 and 1000.5 produced identical frames either way.

Blending is now wired up and implemented with numpy. Two details make it
affordable: slice cached_array directly instead of building two PIL images only
to convert them straight back (the naive version measured 15x the integer path),
and use fixed-point uint16 multiply-add rather than float32, which suits the Pi's
cores and gives finer weighting than the panel can resolve. Result 0.939ms
against 0.237ms — 0.70ms added per frame, a 1065fps ceiling.

Measured on hardware: 81.2 fps with blending on, against 78.7 with it off, so no
cost within noise — and every frame is now a distinct position rather than one in
three being a repeat.

The trade is a slight horizontal softening of text, since each frame blends two
positions. Set smooth_scroll false for maximum crispness.

Also benchmarked and cleared as non-issues: extending the strip costs 9.4ms on an
11,000px strip and trimming 2.5ms, both under one frame at this rate.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ

* Add overflow handling: keep ordered content whole instead of rotating a window

The width budget split any oversized plugin by advancing a window each cycle.
That is right for interchangeable items — news headlines, odds, stock prices —
but wrong for ordered content: a league table showed ranks 1-6, then resumed at
7 two rotations later, which reads as out of order and out of context. Nobody
needs rank 23 in a ticker; they need the top of the table, every time.

overflow_mode chooses between them:

  rotate   — advance a window each cycle so everything is seen eventually
             (unchanged default)
  truncate — always show the start and drop the rest, keeping ordered content
             coherent. Records no window state, so every pass starts at the top.

Per-plugin vegas_overflow overrides the global setting, since one install has
both kinds of plugin. Also adds per-plugin vegas_max_width_screens, so content
that must stay whole can be given more room — or uncapped with 0 — without
lifting the cap on every ticker.

Applied on the test rig: f1-scoreboard and ledmatrix-leaderboard set to
truncate, and baseball given 4.5 screens because it was showing 8 of 9 games
when the whole slate needed only a little more room. Verified: F1 now reports
"the first 10 of 116 ... the rest are not shown", baseball has dropped out of
the budget log entirely, and stocks, odds-ticker and stock-news still rotate.

Also corrects the crop log, which claimed "window advances next cycle"
unconditionally and so misreported truncated crops. A test now pins the
behaviour behind the message: truncate must leave no offset recorded.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ

* Stop Vegas mode showing last night's games as if they were live

A game that was live in the evening was still being drawn as live the next
morning. Two faults combined to freeze plugin visuals indefinitely.

PR #291 added a call to plugin_adapter.invalidate_plugin_scroll_cache() so
a plugin's own cached scroll image would be rebuilt from fresh data. That
method was never implemented. hot_swap_content() wraps the call in a broad
except, so every hot swap has raised AttributeError and been swallowed
silently ever since — which is why the visuals it was meant to keep fresh
never were.

Continuous scrolling then removed the only path that reached it at all:
should_recompose() and hot_swap_content() are called from the
non-continuous branch of run_frame(), and continuous_scroll defaults to
True. So on a default install the pending-update flags were set by the
update tick, never consumed, and grew without bound.

Together these froze content completely, because refetching is not enough
on its own: the sports plugins' get_vegas_content() regenerates only "if
the cache is empty", so take_next_group() kept receiving the same picture
however often it asked.

Fixed by:

- Implementing invalidate_plugin_scroll_cache(). It covers both layouts —
  a helper directly on the plugin (stocks, news, odds-ticker) and one
  owned by a scroll-display manager (the sports scoreboards, which is the
  shape that produced this bug) — and clears cached_image and
  cached_array together, since the array is the image's numpy mirror.

- Adding StreamManager.invalidate_pending_updates() and calling it from
  the continuous branch. It only drops the caches; the plugin recomposes
  when it next comes round in the rotation. process_updates() is wrong
  here: it refetches synchronously and merges into the active buffer that
  continuous mode bypasses, and hot_swap_content() rebuilds and
  repositions the whole strip, which is the freeze-and-jump this mode
  exists to avoid.

Tests assert the fix rather than the implementation: 14 of the 17 new
tests fail without it. Includes the wiring itself, since the regression
was a call that was simply absent, and a check that the scroll position is
untouched so this cannot regress into the swap's visible jump.

All Vegas suites pass (355 tests). test_display_controller_vegas_tick.py
still cannot be collected off-device for want of rgbmatrix, identically
with and without this change.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ

* Fix two CodeRabbit-flagged test assertions in vegas density tests

test_prepared_group_is_used_without_refetching had a tautological final
assertion; now checks stream.calls directly. test_no_partial_letter_at_either_edge
required both crop edges to be blank, but the left edge here is always the
crop's start position with no lead-in gap in word_strip, so it legitimately
carries ink — only the right edge is an actual cut and needs the check.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-31 09:40:38 -04:00
e2acbfb566 fix(web): don't validate double-sided settings when the feature is disabled (#422)
* fix(web): don't validate double-sided settings when the feature is disabled

Saving anything on the Display tab failed with a 400 when double-sided
mode was off:

    Double-sided copies (2) must divide chain length (3) evenly

The Display form posts every field in one request, including
double_sided_copies (default 2) and double_sided_axis, whether or not
the Enabled checkbox is ticked. The server block was gated only on "is
any double-sided field present in the payload" — it wrote
ds_config['enabled'] but never read it. A user with chain_length: 3 and
the untouched default copies: 2 was locked out of saving any display
setting at all: brightness, GPIO slowdown, Vegas, sync.

Gate the checks on the enabled flag:

- Divisibility against chain_length/parallel is hardware-relational and
  only runs when the feature is on.
- Structural checks (copies parses as an int in 2..8, axis in the
  whitelist) still 400 when enabled; when disabled they drop the value
  and leave the stored one untouched rather than rejecting the save.

The runtime already gated correctly (_resolve_double_sided returns None
when disabled), so nothing there changes.

Also in the Display tab:

- Hide Copies / Split Axis until Enabled is ticked, mirroring the Vegas
  Scroll pattern. Hidden rather than disabled, so the fields keep
  submitting and the server still sees an 'off' state to persist.
- Fix the save toast: the form's handler read xhr.responseJSON, a jQuery
  property that doesn't exist on a native XMLHttpRequest, so it was
  always undefined and every save reported a green "Display settings
  saved" — even the 400s. Parse responseText and use the real status.

Tests: two existing double-sided tests asserted 200 on payloads that the
divisibility check (added later, in #373) turns into 400s; the first now
supplies matching hardware values and the second passes as written now
that a disabled save skips the check. Added coverage for the reported
regression, for bad values while disabled, and for the check still
firing when enabled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016aUvzTXpEYhNEWYQvFqQU9

* fix(web): treat only 2xx as a successful display save

Review feedback on #422.

- showDisplaySaveResult tested `xhr.status >= 400` for failure, so a
  network error — which reports status 0 — was waved through as
  "Display settings saved". That's the same class of false-success bug
  this branch set out to fix. Test the 2xx range instead, and let a
  response body refine a successful verdict without overturning a
  failed one.
- Annotate the _copies_fits_hardware helper, matching the annotated
  helpers already in api_v3.py.
- Cover the vertical divisibility branch: chain_length 2 would divide
  evenly, so only parallel 3 can produce the rejection, which pins the
  branch to the right hardware dimension.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016aUvzTXpEYhNEWYQvFqQU9

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-28 16:54:42 -04:00
18 changed files with 5455 additions and 116 deletions
+19 -1
View File
@@ -130,7 +130,25 @@
"plugin_order": [],
"excluded_plugins": [],
"target_fps": 125,
"buffer_ahead": 2
"buffer_ahead": 2,
"intra_plugin_gap": 8,
"render_width_pct": 100,
"min_content_separation": 24,
"min_cut_gap": 6,
"continuous_scroll": true,
"smooth_scroll": true,
"extend_threshold_screens": 2.0,
"auto_trim": true,
"trim_threshold": 10,
"content_padding": 8,
"min_plugin_width": 8,
"lead_in_width": 0,
"plugins_per_cycle": 6,
"max_plugin_width_ratio": 3.0,
"overflow_mode": "rotate",
"dynamic_duration_enabled": true,
"min_cycle_duration": 60,
"max_cycle_duration": 240
}
},
"sync": {
+384
View File
@@ -0,0 +1,384 @@
#!/usr/bin/env python3
"""
Vegas Mode Density Audit
Reports how much of the Vegas ticker is actually showing something. Loads the
real enabled plugins, pulls each one's content through the real
``PluginAdapter``, composes the strip through the real ``ScrollHelper``, then
measures the result.
The headline number is the **dead-frame ratio**: the fraction of viewport
positions across a full cycle that are effectively blank. Because the panel
only ever shows ``display_width`` columns at a time, a blank stretch wider than
the viewport is a stretch where the display looks switched off — so this ratio
tracks perceived dead time rather than just counting unlit pixels.
Runs entirely off-hardware, so it is safe to run alongside a live display.
Usage:
# Audit every enabled plugin at the display size from config.json
python scripts/dev/vegas_audit.py
# Specific plugins, dump each segment as a PNG for eyeballing
python scripts/dev/vegas_audit.py -p of-the-day,youtube-stats --dump-dir /tmp/vg
# Machine-readable, for before/after comparison
python scripts/dev/vegas_audit.py --json > after.json
"""
import argparse
import json
import logging
import os
import sys
import time
from pathlib import Path
from typing import Any, Dict, List
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
# Must precede any src import that may reach for hardware.
os.environ.setdefault('EMULATOR', 'true')
from PIL import Image # noqa: E402
from src.common.scroll_helper import ScrollHelper # noqa: E402
from src.plugin_system.testing.loading import ( # noqa: E402
build_full_config,
find_plugin_dir,
load_manifest,
)
from src.vegas_mode.config import VegasModeConfig # noqa: E402
from src.vegas_mode.geometry import ( # noqa: E402
DEFAULT_INK_THRESHOLD,
column_has_ink,
content_bounds,
dead_window_stats,
window_coverage_stats,
)
from src.vegas_mode.plugin_adapter import PluginAdapter # noqa: E402
# Sampling stride for the dead-window scan. A full cycle can be 30,000px wide;
# 4px granularity keeps the scan instant while staying well under the ~10px a
# single scroll step ever covers, so no dead stretch is missed.
DEAD_SCAN_STEP = 4
def load_main_config(path: Path) -> Dict[str, Any]:
with open(path, 'r') as fh:
return json.load(fh)
def display_size_from_config(config: Dict[str, Any]) -> tuple:
"""Derive the logical ticker size the way DisplayManager does."""
hw = config.get('display', {}).get('hardware', {})
cols = int(hw.get('cols', 64))
chain = int(hw.get('chain_length', 1))
rows = int(hw.get('rows', 32))
parallel = int(hw.get('parallel', 1))
return cols * chain, rows * parallel
def enabled_plugin_ids(config: Dict[str, Any]) -> List[str]:
"""Plugin IDs that are enabled in config, excluding non-plugin sections."""
ids = []
for key, value in config.items():
if isinstance(value, dict) and value.get('enabled') is True:
ids.append(key)
return ids
def instantiate(plugin_id: str, display_manager, cache_manager, plugin_manager):
"""Load one plugin offline. Returns the instance or None."""
from src.plugin_system.plugin_loader import PluginLoader
search_dirs = [
str(PROJECT_ROOT / 'plugin-repos'),
str(PROJECT_ROOT / 'plugins'),
]
plugin_dir = find_plugin_dir(plugin_id, search_dirs)
if not plugin_dir:
return None
try:
manifest = load_manifest(Path(plugin_dir))
cfg = build_full_config(Path(plugin_dir))
instance, _ = PluginLoader().load_plugin(
plugin_id=plugin_id,
manifest=manifest,
plugin_dir=Path(plugin_dir),
config=cfg,
display_manager=display_manager,
cache_manager=cache_manager,
plugin_manager=plugin_manager,
install_deps=False,
)
return instance
except Exception as exc: # noqa: BLE001 - audit tool must survive any plugin
print(f" ! {plugin_id}: load failed ({type(exc).__name__}: {exc})",
file=sys.stderr)
return None
def join_rows(images: List[Image.Image], gap: int) -> Image.Image:
"""Concatenate one plugin's rows, matching RenderPipeline._join_plugin_rows."""
if len(images) == 1:
return images[0]
gap = max(0, gap)
width = sum(img.width for img in images) + gap * (len(images) - 1)
height = max(img.height for img in images)
block = Image.new('RGB', (width, height), (0, 0, 0))
x = 0
for img in images:
block.paste(img, (x, 0))
x += img.width + gap
return block
def measure_segment(images: List[Image.Image], display_width: int,
scroll_speed: float, threshold: int) -> Dict[str, Any]:
"""Geometry of one plugin's contribution to the ticker."""
total_width = sum(img.width for img in images)
combined = Image.new('RGB', (max(1, total_width), images[0].height))
x = 0
for img in images:
combined.paste(img, (x, 0))
x += img.width
ink = column_has_ink(combined, threshold)
bounds = content_bounds(combined, threshold)
ink_cols = int(ink.sum())
return {
'images': len(images),
'width_px': total_width,
'ink_cols': ink_cols,
'ink_pct': round(100.0 * ink_cols / total_width, 1) if total_width else 0.0,
'lead_black_px': bounds[0] if bounds else total_width,
'trail_black_px': (total_width - 1 - bounds[1]) if bounds else 0,
'seconds_on_screen': round(total_width / scroll_speed, 1) if scroll_speed else 0.0,
'widths': [img.width for img in images],
}
def main() -> int:
parser = argparse.ArgumentParser(
description='Audit Vegas mode content density')
parser.add_argument('--config', default=str(PROJECT_ROOT / 'config' / 'config.json'),
help='Path to main config.json')
parser.add_argument('-p', '--plugins', default=None,
help='Comma-separated plugin IDs (default: all enabled)')
parser.add_argument('--width', type=int, default=None,
help='Override display width (default: from config hardware)')
parser.add_argument('--height', type=int, default=None,
help='Override display height (default: from config hardware)')
parser.add_argument('--dump-dir', default=None,
help='Write each segment and the composed strip as PNGs here')
parser.add_argument('--threshold', type=int, default=DEFAULT_INK_THRESHOLD,
help=f'Ink threshold (default: {DEFAULT_INK_THRESHOLD})')
parser.add_argument('--per-cycle', type=int, default=None,
help='Plugins composed per cycle '
'(default: buffer_ahead + 1, matching production)')
parser.add_argument('--json', action='store_true',
help='Emit JSON instead of a text report')
args = parser.parse_args()
config = load_main_config(Path(args.config))
vegas = VegasModeConfig.from_config(config)
cfg_w, cfg_h = display_size_from_config(config)
width = args.width or cfg_w
height = args.height or cfg_h
speed = vegas.scroll_speed
if args.plugins:
plugin_ids = [p.strip() for p in args.plugins.split(',') if p.strip()]
else:
plugin_ids = vegas.get_ordered_plugins(enabled_plugin_ids(config))
dump_dir = Path(args.dump_dir) if args.dump_dir else None
if dump_dir:
dump_dir.mkdir(parents=True, exist_ok=True)
from src.plugin_system.testing import (
MockCacheManager, MockPluginManager, VisualTestDisplayManager,
)
display_manager = VisualTestDisplayManager(width=width, height=height)
cache_manager = MockCacheManager()
plugin_manager = MockPluginManager()
# Pass the loaded config, exactly as VegasModeCoordinator does. Omitting it
# makes PluginAdapter fall back to VegasModeConfig() defaults, so the audit
# would silently report trimming and width-budget behaviour that differs
# from the user's config.json — the same drift the lead_gap and grouping
# arguments below exist to avoid.
adapter = PluginAdapter(display_manager, vegas)
if not args.json:
print(f"Vegas audit — display {width}x{height}, scroll {speed:g}px/s, "
f"separator {vegas.separator_width}px")
print(f"One display width = {width / speed:.1f}s of screen time\n")
results: List[Dict[str, Any]] = []
segments: List[Image.Image] = []
for plugin_id in plugin_ids:
started = time.time()
instance = instantiate(plugin_id, display_manager, cache_manager, plugin_manager)
if instance is None:
results.append({'plugin': plugin_id, 'status': 'load_failed'})
continue
plugin_manager.plugins[plugin_id] = instance
adapter.invalidate_cache(plugin_id)
try:
images = adapter.get_content(instance, plugin_id)
except Exception as exc: # noqa: BLE001
results.append({'plugin': plugin_id, 'status': 'fetch_error',
'error': f'{type(exc).__name__}: {exc}'})
continue
fetch_ms = round((time.time() - started) * 1000)
if not images:
results.append({'plugin': plugin_id, 'status': 'no_content',
'fetch_ms': fetch_ms})
if not args.json:
print(f" {plugin_id:28s} NO CONTENT ({fetch_ms}ms)")
continue
entry = {'plugin': plugin_id, 'status': 'ok', 'fetch_ms': fetch_ms}
entry.update(measure_segment(images, width, speed, args.threshold))
results.append(entry)
segments.extend(images)
if dump_dir:
for idx, img in enumerate(images):
img.save(dump_dir / f"{plugin_id}__{idx:02d}.png")
if not args.json:
print(f" {plugin_id:28s} {entry['width_px']:>6d}px "
f"{entry['images']:>2d} img ink {entry['ink_pct']:>5.1f}% "
f"lead {entry['lead_black_px']:>4d} tail {entry['trail_black_px']:>4d} "
f"{entry['seconds_on_screen']:>6.1f}s ({fetch_ms}ms)")
summary: Dict[str, Any] = {
'display_width': width,
'display_height': height,
'scroll_speed': speed,
'separator_width': vegas.separator_width,
'plugins_audited': len(plugin_ids),
'plugins_with_content': sum(1 for r in results if r.get('status') == 'ok'),
}
# Production composes only the plugins sitting in the active buffer, so
# measuring one giant strip of every plugin would hide the per-cycle costs
# (most importantly the leading gap, which is charged once per cycle).
# Group the segments the way the running service does.
per_cycle = max(1, args.per_cycle or vegas.plugins_per_cycle)
cycles: List[Dict[str, Any]] = []
with_content = [r for r in results if r.get('status') == 'ok']
if segments:
logger = logging.getLogger('vegas_audit')
seg_index = 0
for start in range(0, len(with_content), per_cycle):
group = with_content[start:start + per_cycle]
# Mirror RenderPipeline: each plugin's rows are joined by
# intra_plugin_gap into one block, and separator_width is applied
# only between blocks. Measuring a flat list here would report gaps
# the service does not emit.
blocks: List[Image.Image] = []
for entry in group:
count = entry['images']
rows = segments[seg_index:seg_index + count]
seg_index += count
if rows:
blocks.append(join_rows(rows, vegas.intra_plugin_gap))
if not blocks:
continue
# ScrollHelper logs unconditionally, so it needs a real logger.
helper = ScrollHelper(width, height, logger)
helper.create_scrolling_image(
content_items=blocks,
item_gap=vegas.separator_width,
element_gap=0,
# Must match RenderPipeline. Omitting this made the audit
# measure a full-display-width leading gap the service no
# longer emits, overstating dead space by 512px per cycle.
lead_gap=vegas.lead_in_width,
)
composed = helper.cached_image
if composed is None:
continue
dead = dead_window_stats(composed, width, args.threshold, step=DEAD_SCAN_STEP)
cover = window_coverage_stats(
composed, width, args.threshold, step=DEAD_SCAN_STEP)
if dump_dir:
composed.save(dump_dir / f"_cycle{len(cycles):02d}.png")
cycles.append({
'plugins': [e['plugin'] for e in group],
'width_px': composed.width,
'seconds': round(composed.width / speed, 1) if speed else 0.0,
'dead_pct': round(100 * dead.dead_ratio, 1),
'longest_dead_seconds': round(
dead.longest_dead_run * DEAD_SCAN_STEP / speed, 1) if speed else 0.0,
'mean_ink_pct': round(100 * cover.mean_ink_ratio, 1),
'sparse_pct': round(100 * cover.sparse_ratio, 1),
'longest_sparse_seconds': round(
cover.longest_sparse_run * DEAD_SCAN_STEP / speed, 1) if speed else 0.0,
})
if cycles:
total_px = sum(c['width_px'] for c in cycles)
# Weight each cycle by its width so a long cycle counts proportionally.
summary.update({
'cycles': len(cycles),
'total_px': total_px,
'full_rotation_seconds': round(total_px / speed, 1) if speed else 0.0,
'dead_pct': round(
sum(c['dead_pct'] * c['width_px'] for c in cycles) / total_px, 1),
'mean_ink_pct': round(
sum(c['mean_ink_pct'] * c['width_px'] for c in cycles) / total_px, 1),
'sparse_pct': round(
sum(c['sparse_pct'] * c['width_px'] for c in cycles) / total_px, 1),
'worst_dead_seconds': max(c['longest_dead_seconds'] for c in cycles),
'worst_sparse_seconds': max(c['longest_sparse_seconds'] for c in cycles),
})
if args.json:
print(json.dumps({'summary': summary, 'cycles': cycles, 'plugins': results},
indent=2))
else:
print(f"\n Cycles ({per_cycle} plugins each, as production composes them):")
for idx, cyc in enumerate(cycles):
print(f" [{idx}] {cyc['width_px']:>6d}px {cyc['seconds']:>6.1f}s "
f"ink {cyc['mean_ink_pct']:>5.1f}% blank {cyc['dead_pct']:>5.1f}% "
f"worst blank {cyc['longest_dead_seconds']:>5.1f}s "
f"| {', '.join(cyc['plugins'])}")
print(f"\n {'-' * 66}")
print(f" full rotation {summary.get('full_rotation_seconds', 0):>7.1f}s "
f"over {summary.get('cycles', 0)} cycles")
print(f" mean ink coverage {summary.get('mean_ink_pct', 0):>7.1f}% "
f"(higher is better; target >25%)")
print(f" fully blank {summary.get('dead_pct', 0):>7.1f}% (target <2%)")
print(f" reads as empty {summary.get('sparse_pct', 0):>7.1f}% (target <15%)")
print(f" worst blank stretch {summary.get('worst_dead_seconds', 0):>7.1f}s "
f"(target <1.5s)")
print(f" plugins w/ content {summary.get('plugins_with_content', 0):>7d}"
f" of {summary['plugins_audited']}")
return 0
if __name__ == '__main__':
raise SystemExit(main())
+203 -12
View File
@@ -110,20 +110,30 @@ class ScrollHelper:
self.is_scrolling = False
self.scroll_complete = False
def create_scrolling_image(self, content_items: list,
def create_scrolling_image(self, content_items: list,
item_gap: int = 32,
element_gap: int = 16) -> Image.Image:
element_gap: int = 16,
lead_gap: Optional[int] = None) -> Image.Image:
"""
Create a wide image containing all content items for scrolling.
Args:
content_items: List of PIL Images to include in scroll
item_gap: Gap between different items
element_gap: Gap between elements within an item
lead_gap: Blank columns before the first item. Defaults to a full
display width, which makes a standalone ticker scroll in from
off-screen. Callers that loop many plugins back-to-back (Vegas
mode) pass a smaller value, since a full display width of black
reads as the panel being switched off at the start of every
cycle.
Returns:
PIL Image containing all content arranged horizontally
"""
if lead_gap is None:
lead_gap = self.display_width
lead_gap = max(0, int(lead_gap))
if not content_items:
# Create empty image if no content
# Still set total_scroll_width to 0 to indicate no scrollable content
@@ -144,13 +154,13 @@ class ScrollHelper:
total_width += element_gap * len(content_items)
# Add initial gap before first item
total_width += self.display_width
total_width += lead_gap
# Create the full scrolling image
full_image = Image.new('RGB', (total_width, self.display_height), (0, 0, 0))
# Position items
current_x = self.display_width # Start with initial gap
current_x = lead_gap # Start with initial gap
for i, img in enumerate(content_items):
# Paste the item image
@@ -338,13 +348,72 @@ class ScrollHelper:
"""
if not self.cached_image or self.cached_array is None:
return None
# Use integer pixel positioning for high FPS scrolling (like stock ticker)
start_x_int = int(self.scroll_position)
end_x_int = start_x_int + self.display_width
# Fast integer pixel path (no interpolation - high frame rate provides smoothness)
# Integer positioning quantises motion to whole pixels, so the number of
# distinct frames per second equals the scroll speed in px/s, no matter
# how fast the loop renders. At 50px/s and 78fps that made 36% of frames
# identical: the extra frames cost work and bought nothing. Blending
# between the two neighbouring positions gives motion at the frame rate
# instead of the step rate.
if self.sub_pixel_scrolling:
fractional = self.scroll_position - start_x_int
if fractional > 0.0:
return self._blend_visible_portion(start_x_int, fractional)
return self._get_visible_portion_integer(start_x_int, end_x_int)
def _blend_visible_portion(self, start_x: int, fractional: float) -> Image.Image:
"""
Linear blend between the frames at ``start_x`` and ``start_x + 1``.
Implemented with numpy rather than scipy.ndimage.shift: scipy is not
installed on the target devices (HAS_SCIPY is False there), which is why
the pre-existing sub-pixel path was dead code — get_visible_portion never
consulted the flag, and the scipy fallback would not have interpolated
anyway.
Args:
start_x: Left column of the earlier of the two frames
fractional: How far between the two, in [0, 1)
Returns:
The blended frame
"""
width = self.display_width
strip_width = self.cached_array.shape[1]
if start_x + width + 1 <= strip_width:
# Slice the backing array directly. Going via
# _get_visible_portion_integer would build two PIL images only for
# them to be converted straight back to arrays, which measured 15x
# the cost of the integer path.
near = self.cached_array[:, start_x:start_x + width]
far = self.cached_array[:, start_x + 1:start_x + 1 + width]
else:
# Close enough to the end that one of the slices wraps; let the
# integer path handle that and pay the conversion. Continuous mode
# extends the strip before reaching here, so this is the rare case.
near = np.asarray(
self._get_visible_portion_integer(start_x, start_x + width))
far = np.asarray(
self._get_visible_portion_integer(start_x + 1, start_x + 1 + width))
# Fixed-point rather than float32: integer multiply-add on uint16 is
# markedly faster than float maths on the Pi's ARM cores, and 8 bits of
# weight is finer than the panel can show.
weight = int(fractional * 256.0)
blended = (
(near.astype(np.uint16) * (256 - weight)
+ far.astype(np.uint16) * weight) >> 8
).astype(np.uint8)
return Image.frombytes(
'RGB', (width, self.display_height),
np.ascontiguousarray(blended).tobytes()
)
def _get_visible_portion_integer(self, start_x: int, end_x: int) -> Image.Image:
"""Fast integer pixel extraction (no interpolation).
@@ -638,6 +707,128 @@ class ScrollHelper:
"""
return self.scroll_complete
def append_content(self, content_items: list,
item_gap: int = 32,
element_gap: int = 0) -> bool:
"""
Append items to the right of the existing strip, preserving scroll state.
Lets a caller keep one continuous strip instead of replacing it. Vegas
mode uses this so the next group of plugins scrolls in from the right
rather than the strip being swapped out underneath the viewer — a swap
shows as a flash and a hard cut to already-full-screen content.
``scroll_position`` and ``total_distance_scrolled`` are untouched, so
motion continues uninterrupted; only the strip gets longer. Because
completion is measured against ``total_scroll_width``, extending the
strip also defers completion, which is the intent.
Args:
content_items: Images to append, in order
item_gap: Gap between appended items, and between the existing
content and the first appended item
element_gap: Extra gap after each item, mirroring
create_scrolling_image
Returns:
True if content was appended
"""
if not content_items:
return False
if self.cached_image is None or self.cached_array is None:
# Nothing to extend yet — this is just the first build.
self.create_scrolling_image(
content_items, item_gap=item_gap, element_gap=element_gap, lead_gap=0)
return True
gap = max(0, item_gap)
addition_width = (
sum(img.width for img in content_items)
+ gap * len(content_items) # one leading gap per item
+ element_gap * len(content_items)
)
addition = Image.new('RGB', (addition_width, self.display_height), (0, 0, 0))
x = 0
for img in content_items:
x += gap # separate from whatever precedes
addition.paste(img, (x, 0))
x += img.width + element_gap
# numpy concatenate then one conversion back, rather than allocating a
# full-width PIL image and pasting twice: the strip can be tens of
# thousands of columns wide and this runs on the render path.
self.cached_array = np.concatenate(
(self.cached_array, np.array(addition)), axis=1)
self.cached_image = Image.fromarray(self.cached_array)
self.total_scroll_width = self.cached_image.width
self.scroll_complete = False
self.logger.info(
"Appended %d item(s) (%dpx) to scroll strip: now %dpx, position %.0f",
len(content_items), addition_width, self.total_scroll_width,
self.scroll_position
)
return True
def drop_scrolled_prefix(self, keep_before: int = 0) -> int:
"""
Discard columns that have already scrolled past, to bound memory.
A continuously extended strip would otherwise grow without limit. All
the positional state is shifted by the amount removed so the visible
frame and the completion arithmetic are unchanged:
``total_distance_scrolled`` and ``total_scroll_width`` both shrink by the
same amount, preserving their difference.
Args:
keep_before: Columns to retain behind the current position, as a
safety margin against a caller reading slightly behind it
Returns:
Number of columns actually removed
"""
if self.cached_image is None or self.cached_array is None:
return 0
# While the viewport wraps, get_visible_portion fills its right-hand side
# from the *head* of the strip, so trimming the head would change what
# is on screen. Continuous mode extends before ever reaching that state;
# refusing here keeps "trimming is invisible" true unconditionally.
if self.scroll_position + self.display_width > self.cached_image.width:
return 0
cut = int(self.scroll_position) - max(0, keep_before)
if cut <= 0:
return 0
# Never trim so far that the remaining strip is narrower than the
# viewport, or get_visible_portion has nothing to slice.
cut = min(cut, max(0, self.cached_image.width - self.display_width))
if cut <= 0:
return 0
# .copy() so the original buffer is released rather than kept alive by
# a numpy view.
self.cached_array = self.cached_array[:, cut:].copy()
self.cached_image = Image.fromarray(self.cached_array)
self.total_scroll_width = self.cached_image.width
self.scroll_position -= cut
self.total_distance_scrolled = max(0.0, self.total_distance_scrolled - cut)
self.logger.debug(
"Dropped %dpx of scrolled strip: now %dpx, position %.0f",
cut, self.total_scroll_width, self.scroll_position
)
return cut
def remaining_unscrolled(self) -> int:
"""Columns of strip still to the right of the viewport."""
if self.cached_image is None:
return 0
return max(0, self.total_scroll_width - int(self.scroll_position)
- self.display_width)
def reset_scroll(self) -> None:
"""
Reset scroll position to beginning.
+70 -2
View File
@@ -186,8 +186,14 @@ class DisplayManager:
self.config = config or {}
self._force_fallback = force_fallback
self._suppress_test_pattern = suppress_test_pattern
# When True, update_display() and clear() skip hardware writes (used during off-screen content capture)
self._capture_mode_active = False
# Per-thread capture state. update_display() and clear() skip hardware
# writes while the *calling* thread is capturing content off-screen.
#
# Thread-local rather than a plain flag because Vegas mode prepares
# upcoming content on a background thread: a shared flag set there would
# suppress the render loop's own frame pushes for the duration, freezing
# the panel exactly when the point was to avoid a freeze.
self._capture_state = threading.local()
# Double-sided mode state (resolved in _setup_matrix). When disabled,
# the logical image is blitted to the matrix unchanged.
self._double_sided = None # dict {copies, axis, logical_width, logical_height} or None
@@ -520,6 +526,15 @@ class DisplayManager:
except Exception as e:
logger.error(f"Error drawing test pattern: {e}", exc_info=True)
@property
def _capture_mode_active(self) -> bool:
"""True while the calling thread is capturing content off-screen."""
return getattr(self._capture_state, 'active', False)
@_capture_mode_active.setter
def _capture_mode_active(self, value: bool) -> None:
self._capture_state.active = bool(value)
@contextmanager
def capture_mode(self):
"""Suppress hardware output during off-screen content capture.
@@ -536,6 +551,59 @@ class DisplayManager:
finally:
self._capture_mode_active = False
@contextmanager
def render_size(self, width: int, height: Optional[int] = None):
"""Temporarily present a smaller logical canvas to plugins.
Plugins lay out against ``display_manager.matrix.width`` (and the
``width``/``height`` properties, which defer to it), so the only way to
get a *narrower layout* rather than a cropped one is to tell the plugin
the screen is narrower while it renders. Trimming after the fact cannot
fix a forecast spread across five columns or a progress bar drawn at
100% width — those need the plugin to make different layout decisions.
Vegas mode uses this so a plugin can occupy a fraction of a wide panel
and still look deliberately composed. Reuses the same _LogicalMatrix
indirection that double-sided mode relies on, so plugins see a
consistent size from every accessor.
Only meaningful inside :meth:`capture_mode` — this swaps the shared
image buffer, so the render loop must not be writing to it concurrently.
Args:
width: Logical width to report, clamped to at least 1 and to the
real panel width (a larger canvas would overflow the hardware).
height: Logical height, defaulting to the current height.
"""
real_matrix = self.matrix
prev_image = getattr(self, 'image', None)
prev_draw = getattr(self, 'draw', None)
current_w = self.width
current_h = self.height
target_w = max(1, min(int(width), current_w))
target_h = max(1, min(int(height) if height else current_h, current_h))
if target_w == current_w and target_h == current_h:
# Nothing to do; avoid pointless wrapping and buffer churn.
yield
return
try:
if real_matrix is not None:
self.matrix = _LogicalMatrix(real_matrix, target_w, target_h)
# With no hardware, the width/height properties fall through to
# self.image, so swapping the buffer below is enough on its own.
self.image = Image.new('RGB', (target_w, target_h))
self.draw = ImageDraw.Draw(self.image)
yield
finally:
self.matrix = real_matrix
if prev_image is not None:
self.image = prev_image
if prev_draw is not None:
self.draw = prev_draw
def _composite_double_sided(self):
"""Tile the logical screen across the full physical chain.
+34
View File
@@ -505,6 +505,40 @@ class BasePlugin(ABC):
# -------------------------------------------------------------------------
# Vegas scroll mode support
# -------------------------------------------------------------------------
def get_vegas_render_width(self) -> int:
"""
Width the Vegas ticker wants this plugin's content to occupy.
On a wide panel a layout built to fill the screen reads as sparse in a
ticker a forecast spread over five columns, a progress bar drawn at
100% width, a stat block with the panel's whole width between its
elements. Vegas asks for a narrower render so the plugin can choose a
tighter arrangement instead of being cropped afterwards.
Vegas also narrows ``display_manager`` for the duration of the call, so
a plugin that already sizes itself from ``matrix.width`` needs no
changes. Read this only when you size content some other way.
Controlled by the plugin's own ``vegas_width_pct`` config value, else
the global ``display.vegas_scroll.render_width_pct``.
Returns:
Target width in pixels. Outside a Vegas content request, the full
display width.
"""
requested = getattr(self, '_vegas_render_width', None)
if isinstance(requested, int) and requested > 0:
return requested
display_manager = getattr(self, 'display_manager', None)
matrix = getattr(display_manager, 'matrix', None)
if matrix is not None and getattr(matrix, 'width', None):
return int(matrix.width)
width = getattr(display_manager, 'width', None)
if callable(width):
width = width()
return int(width) if width else 128
def get_vegas_content(self) -> Optional[Any]:
"""
Get content for Vegas-style continuous scroll mode.
@@ -15,6 +15,7 @@ PIL Image canvas and draws text using the actual project fonts.
import math
import os
import time
from contextlib import contextmanager
from pathlib import Path
from typing import Any, List, Optional, Tuple
@@ -62,6 +63,9 @@ class VisualTestDisplayManager:
# Matrix proxy (plugins access display_manager.matrix.width/height)
self.matrix = _MatrixProxy(width, height)
# Set while inside capture_mode(); mirrors DisplayManager's flag.
self._capture_mode_active = False
# Scrolling state (interface compat, no-op)
self._scrolling_state = {
'is_scrolling': False,
@@ -174,6 +178,50 @@ class VisualTestDisplayManager:
"""No-op for hardware; marks that display was updated."""
self.update_called = True
@contextmanager
def render_size(self, width: int, height: Optional[int] = None):
"""
Interface parity with DisplayManager.render_size().
Vegas mode narrows the canvas so plugins lay out compactly instead of
being cropped. The harness must offer the same context or that path
cannot be exercised offline and because the adapter catches broadly,
a missing method shows up as "no content" rather than an error.
"""
prev_image = self.image
prev_draw = self.draw
prev_w, prev_h = self._width, self._height
target_w = max(1, min(int(width), prev_w))
target_h = max(1, min(int(height) if height else prev_h, prev_h))
try:
self._width, self._height = target_w, target_h
self.matrix = _MatrixProxy(target_w, target_h)
self.image = Image.new('RGB', (target_w, target_h), (0, 0, 0))
self.draw = ImageDraw.Draw(self.image)
yield
finally:
self._width, self._height = prev_w, prev_h
self.matrix = _MatrixProxy(prev_w, prev_h)
self.image = prev_image
self.draw = prev_draw
@contextmanager
def capture_mode(self):
"""
Interface parity with DisplayManager.capture_mode().
There is no hardware to suppress here, but Vegas mode's PluginAdapter
wraps every off-screen content fetch in this context, so the harness
must provide it for that code path to be exercisable in tests.
"""
self._capture_mode_active = True
try:
yield
finally:
self._capture_mode_active = False
def draw_text(self, text: str, x: Optional[int] = None, y: Optional[int] = None,
color: Tuple[int, int, int] = (255, 255, 255), small_font: bool = False,
font: Optional[Any] = None, centered: bool = False) -> None:
+222
View File
@@ -21,6 +21,94 @@ class VegasModeConfig:
scroll_speed: float = 50.0 # Pixels per second
separator_width: int = 32 # Gap between plugins (pixels)
# Fraction of the panel width a plugin is told it has while rendering for
# the ticker, as a percentage. Trimming can only remove blank margins; it
# cannot compact a layout that genuinely spans the display — a five-column
# forecast, a full-width progress bar, a centred stat block with the panel's
# whole width between its elements. Rendering at a narrower size makes the
# plugin choose a tighter layout instead. 100 disables it.
render_width_pct: int = 100
# Minimum blank columns guaranteed between adjacent content, measured from
# actual ink rather than added blindly. A flat additive gap leaves
# card-style content nearly touching when the cards are drawn flush to their
# own edges, while padding out content that already has wide margins.
min_content_separation: int = 24
# Gap between rows contributed by the *same* plugin. separator_width marks
# the handoff from one plugin to the next; applying it between every image
# forced a 32px chasm between each row of a per-row ticker (the F1
# scoreboard renders its own rows 4px apart), which both looked wrong and
# silently inflated the width that plugin occupied.
intra_plugin_gap: int = 8
# Content density
#
# Plugins that render onto a full-display canvas contribute that whole
# canvas to the ticker, blank margins included. On a wide panel that is the
# dominant source of dead air: a plugin drawing 35px of text on a 512px
# canvas otherwise buys 9.5s of black at 50px/s. Trimming reclaims it.
auto_trim: bool = True
trim_threshold: int = 10 # Per-channel value a pixel must exceed to be "ink"
content_padding: int = 8 # Blank columns kept either side of trimmed content
min_plugin_width: int = 8 # Segments narrower than this after trim are dropped
# Columns of blank lead-in before the first item of a cycle. ScrollHelper
# defaults this to a full display width, which reads as the display being
# switched off at the start of every cycle.
lead_in_width: int = 0
# Blend between neighbouring pixel positions so motion happens at the frame
# rate rather than the scroll speed. With integer positioning the number of
# distinct frames per second equals scroll_speed, so at 50px/s the motion is
# 50 discrete 1px steps however fast the loop runs. The trade is a slight
# horizontal softening of text, since each frame is a blend of two positions.
smooth_scroll: bool = True
# 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
# swapping it in. A swap stops the motion, substitutes every pixel at once
# and restarts with the viewport already full — read as a freeze, a flash
# and a jump. Extending means the next group simply scrolls in from the
# right. Set false to restore the swap behaviour.
continuous_scroll: bool = True
# Extend once the unscrolled remainder falls below this many screen widths.
# Needs to be more than one so the join is prepared before it is on screen.
extend_threshold_screens: float = 2.0
# How many plugins are composed into one scroll cycle. Kept separate from
# buffer_ahead (which is only a prefetch low-water mark) because the two
# were previously the same number: a buffer_ahead of 2 meant just 3 plugins
# per cycle, so a 20-plugin install took seven cycles to come around.
plugins_per_cycle: int = 6
# Minimum run of blank columns that counts as a boundary between items when
# an oversized segment has to be narrowed. Measured on rendered text, the
# gaps between characters are a single column while gaps between items are
# 8px and up, so anything above 1 stops a cut landing inside a word. Cutting
# mid-word orphaned the tail into the next cycle, which showed up as a lone
# letter floating between two unrelated plugins.
min_cut_gap: int = 6
# What to do when a plugin's content exceeds its width budget.
#
# "rotate" — advance a window each cycle so everything is seen eventually.
# Right for interchangeable items: news headlines, odds, stocks.
# "truncate" — always show the start. Right for ordered content, where a
# window into the middle is meaningless: a league table that
# shows ranks 1-6 then resumes at 7 two rotations later reads
# as out of order and out of context.
#
# Override per plugin with vegas_overflow.
overflow_mode: str = "rotate"
# Cap on one plugin's share of a cycle, as a multiple of display width.
# A single ticker returning 7,000px would otherwise hold the panel for over
# two minutes. Overflow is deferred to later cycles rather than discarded.
# 0 disables the cap.
max_plugin_width_ratio: float = 3.0
# Plugin management
plugin_order: List[str] = field(default_factory=list)
excluded_plugins: Set[str] = field(default_factory=set)
@@ -55,6 +143,24 @@ class VegasModeConfig:
enabled=vegas_config.get('enabled', False),
scroll_speed=float(vegas_config.get('scroll_speed', 50.0)),
separator_width=int(vegas_config.get('separator_width', 32)),
intra_plugin_gap=int(vegas_config.get('intra_plugin_gap', 8)),
render_width_pct=int(vegas_config.get('render_width_pct', 100)),
min_content_separation=int(
vegas_config.get('min_content_separation', 24)),
min_cut_gap=int(vegas_config.get('min_cut_gap', 6)),
smooth_scroll=vegas_config.get('smooth_scroll', True),
continuous_scroll=vegas_config.get('continuous_scroll', True),
extend_threshold_screens=float(
vegas_config.get('extend_threshold_screens', 2.0)),
auto_trim=vegas_config.get('auto_trim', True),
trim_threshold=int(vegas_config.get('trim_threshold', 10)),
content_padding=int(vegas_config.get('content_padding', 8)),
min_plugin_width=int(vegas_config.get('min_plugin_width', 8)),
lead_in_width=int(vegas_config.get('lead_in_width', 0)),
plugins_per_cycle=int(vegas_config.get('plugins_per_cycle', 6)),
max_plugin_width_ratio=float(
vegas_config.get('max_plugin_width_ratio', 3.0)),
overflow_mode=str(vegas_config.get('overflow_mode', 'rotate')),
plugin_order=list(vegas_config.get('plugin_order', [])),
excluded_plugins=set(vegas_config.get('excluded_plugins', [])),
target_fps=int(vegas_config.get('target_fps', 125)),
@@ -72,6 +178,21 @@ class VegasModeConfig:
'enabled': self.enabled,
'scroll_speed': self.scroll_speed,
'separator_width': self.separator_width,
'intra_plugin_gap': self.intra_plugin_gap,
'render_width_pct': self.render_width_pct,
'min_content_separation': self.min_content_separation,
'min_cut_gap': self.min_cut_gap,
'smooth_scroll': self.smooth_scroll,
'continuous_scroll': self.continuous_scroll,
'extend_threshold_screens': self.extend_threshold_screens,
'auto_trim': self.auto_trim,
'trim_threshold': self.trim_threshold,
'content_padding': self.content_padding,
'min_plugin_width': self.min_plugin_width,
'lead_in_width': self.lead_in_width,
'plugins_per_cycle': self.plugins_per_cycle,
'max_plugin_width_ratio': self.max_plugin_width_ratio,
'overflow_mode': self.overflow_mode,
'plugin_order': self.plugin_order,
'excluded_plugins': list(self.excluded_plugins),
'target_fps': self.target_fps,
@@ -157,6 +278,74 @@ class VegasModeConfig:
if self.buffer_ahead > 5:
errors.append(f"buffer_ahead must be <= 5, got {self.buffer_ahead}")
if not 10 <= self.render_width_pct <= 100:
errors.append(
"render_width_pct must be between 10 and 100, "
f"got {self.render_width_pct}")
if not 0 <= self.min_content_separation <= 256:
errors.append(
"min_content_separation must be between 0 and 256, "
f"got {self.min_content_separation}")
if not 1.0 <= self.extend_threshold_screens <= 10.0:
errors.append(
"extend_threshold_screens must be between 1.0 and 10.0, "
f"got {self.extend_threshold_screens}")
if not 1 <= self.min_cut_gap <= 128:
errors.append(
"min_cut_gap must be between 1 and 128, "
f"got {self.min_cut_gap}")
if self.intra_plugin_gap < 0:
errors.append(
f"intra_plugin_gap must be >= 0, got {self.intra_plugin_gap}")
if self.intra_plugin_gap > 128:
errors.append(
f"intra_plugin_gap must be <= 128, got {self.intra_plugin_gap}")
if not 0 <= self.trim_threshold <= 254:
errors.append(
f"trim_threshold must be between 0 and 254, got {self.trim_threshold}")
if self.content_padding < 0:
errors.append(
f"content_padding must be >= 0, got {self.content_padding}")
if self.content_padding > 128:
errors.append(
f"content_padding must be <= 128, got {self.content_padding}")
if self.min_plugin_width < 0:
errors.append(
f"min_plugin_width must be >= 0, got {self.min_plugin_width}")
# Bounded because every segment narrower than this is dropped — an
# unbounded value would discard every plugin and leave a blank ticker.
if self.min_plugin_width > 512:
errors.append(
f"min_plugin_width must be <= 512, got {self.min_plugin_width}")
if self.lead_in_width < 0:
errors.append(
f"lead_in_width must be >= 0, got {self.lead_in_width}")
if self.plugins_per_cycle < 1:
errors.append(
f"plugins_per_cycle must be >= 1, got {self.plugins_per_cycle}")
if self.plugins_per_cycle > 50:
errors.append(
f"plugins_per_cycle must be <= 50, got {self.plugins_per_cycle}")
if self.overflow_mode not in ('rotate', 'truncate'):
errors.append(
"overflow_mode must be 'rotate' or 'truncate', "
f"got {self.overflow_mode!r}")
if self.max_plugin_width_ratio < 0:
errors.append(
"max_plugin_width_ratio must be >= 0 "
f"(0 disables the cap), got {self.max_plugin_width_ratio}")
return errors
def update(self, new_config: Dict[str, Any]) -> None:
@@ -174,6 +363,39 @@ class VegasModeConfig:
self.scroll_speed = float(vegas_config['scroll_speed'])
if 'separator_width' in vegas_config:
self.separator_width = int(vegas_config['separator_width'])
if 'intra_plugin_gap' in vegas_config:
self.intra_plugin_gap = int(vegas_config['intra_plugin_gap'])
if 'render_width_pct' in vegas_config:
self.render_width_pct = int(vegas_config['render_width_pct'])
if 'min_content_separation' in vegas_config:
self.min_content_separation = int(
vegas_config['min_content_separation'])
if 'min_cut_gap' in vegas_config:
self.min_cut_gap = int(vegas_config['min_cut_gap'])
if 'smooth_scroll' in vegas_config:
self.smooth_scroll = vegas_config['smooth_scroll']
if 'continuous_scroll' in vegas_config:
self.continuous_scroll = vegas_config['continuous_scroll']
if 'extend_threshold_screens' in vegas_config:
self.extend_threshold_screens = float(
vegas_config['extend_threshold_screens'])
if 'auto_trim' in vegas_config:
self.auto_trim = vegas_config['auto_trim']
if 'trim_threshold' in vegas_config:
self.trim_threshold = int(vegas_config['trim_threshold'])
if 'content_padding' in vegas_config:
self.content_padding = int(vegas_config['content_padding'])
if 'min_plugin_width' in vegas_config:
self.min_plugin_width = int(vegas_config['min_plugin_width'])
if 'lead_in_width' in vegas_config:
self.lead_in_width = int(vegas_config['lead_in_width'])
if 'plugins_per_cycle' in vegas_config:
self.plugins_per_cycle = int(vegas_config['plugins_per_cycle'])
if 'max_plugin_width_ratio' in vegas_config:
self.max_plugin_width_ratio = float(
vegas_config['max_plugin_width_ratio'])
if 'overflow_mode' in vegas_config:
self.overflow_mode = str(vegas_config['overflow_mode'])
if 'plugin_order' in vegas_config:
self.plugin_order = list(vegas_config['plugin_order'])
if 'excluded_plugins' in vegas_config:
+64 -13
View File
@@ -64,7 +64,7 @@ class VegasModeCoordinator:
self.plugin_manager = plugin_manager
# Initialize components
self.plugin_adapter = PluginAdapter(display_manager)
self.plugin_adapter = PluginAdapter(display_manager, self.vegas_config)
self.stream_manager = StreamManager(
self.vegas_config,
plugin_manager,
@@ -233,6 +233,11 @@ class VegasModeCoordinator:
self._should_stop = False
self._start_time = time.time()
# Line up the next group immediately, so the first extension is already
# warm rather than stalling the scroll to fetch it.
if self.vegas_config.continuous_scroll:
self.render_pipeline.start_prefetch()
logger.info("Vegas mode started")
return True
@@ -301,16 +306,43 @@ class VegasModeCoordinator:
if has_pending_update:
self._apply_pending_config()
# Check if we need to start a new cycle
if self.render_pipeline.is_cycle_complete():
if not self.render_pipeline.start_new_cycle():
logger.warning("Failed to start new Vegas cycle")
return False
self.stats['cycles_completed'] += 1
if self.vegas_config.continuous_scroll:
# Drop cached content for plugins whose data just changed, so the
# next time each comes round it is composed from current data. The
# swap path's hot_swap_content() does this via process_updates(),
# but it also rebuilds and repositions the whole strip, which is
# the freeze-and-jump this mode exists to avoid. Without this the
# pending-update flags are never consumed and a segment keeps
# rendering whatever it was first built from — last night's live
# game still shown as live the next morning.
self.render_pipeline.refresh_updated_plugins()
# Check for hot-swap opportunities
if self.render_pipeline.should_recompose():
self.render_pipeline.hot_swap_content()
# Extend the strip before the scroll can reach its end, so the next
# group arrives from the right and motion never stops. No cycle
# boundary, so no freeze, no substitution and no restart with the
# viewport already full.
# Trickle in the plugins that can only be fetched here, one per
# frame, before considering a further extension.
if self.render_pipeline.has_deferred():
self.render_pipeline.drain_deferred()
elif self.render_pipeline.needs_extension():
if self.render_pipeline.extend_scroll_content():
self.stats['cycles_completed'] += 1
elif self.render_pipeline.is_cycle_complete():
# Extension failed and the strip has run out: fall back to
# the swap rather than sitting on a dead frame.
self.render_pipeline.start_new_cycle()
else:
# Check if we need to start a new cycle
if self.render_pipeline.is_cycle_complete():
if not self.render_pipeline.start_new_cycle():
logger.warning("Failed to start new Vegas cycle")
return False
self.stats['cycles_completed'] += 1
# Check for hot-swap opportunities
if self.render_pipeline.should_recompose():
self.render_pipeline.hot_swap_content()
# Render frame
return self.render_pipeline.render_frame()
@@ -337,7 +369,14 @@ class VegasModeCoordinator:
self._update_static_mode_plugins()
frame_interval = self.vegas_config.get_frame_interval()
duration = self.render_pipeline.get_dynamic_duration()
if self.vegas_config.continuous_scroll:
# The strip is continuously extended and trimmed, so its width says
# nothing about how long to run. This is only how often control
# returns to the display controller; interrupts are still checked
# every few frames, so it costs nothing to make it a fixed period.
duration = float(self.vegas_config.max_cycle_duration)
else:
duration = self.render_pipeline.get_dynamic_duration()
start_time = time.time()
frame_count = 0
fps_log_interval = 5.0 # Log FPS every 5 seconds
@@ -347,6 +386,8 @@ class VegasModeCoordinator:
logger.info("Starting Vegas iteration for %.1fs", duration)
while True:
frame_started = time.time()
# Check for STATIC mode plugin that should pause scroll
static_plugin = self._check_static_plugin_trigger()
if static_plugin:
@@ -367,8 +408,14 @@ class VegasModeCoordinator:
# Paused for live priority - let caller handle
return False
# Sleep for frame interval
time.sleep(frame_interval)
# Sleep only the remainder of the frame budget. This used to sleep
# the whole interval on top of however long the frame took, so at a
# measured 31.6ms per frame a fixed 8ms of that was pure idle — a
# quarter of the budget spent not rendering. Subtracting the work
# already done keeps the pacing target while reclaiming that time,
# and yields the GIL either way so other threads still run.
frame_elapsed = time.time() - frame_started
time.sleep(max(0.0, frame_interval - frame_elapsed))
# Increment frame count and check for interrupt periodically
frame_count += 1
@@ -505,6 +552,10 @@ class VegasModeCoordinator:
# Update components
self.render_pipeline.update_config(new_vegas_config)
self.stream_manager.config = new_vegas_config
self.plugin_adapter.config = new_vegas_config
# Cached segments were trimmed under the old settings, so drop them
# or a changed trim/padding value would not visibly take effect.
self.plugin_adapter.invalidate_cache()
# Force refresh of stream manager to pick up plugin_order/buffer changes
self.stream_manager._last_refresh = 0
+474
View File
@@ -0,0 +1,474 @@
"""
Geometry primitives for Vegas Mode.
Pure, side-effect-free measurements over PIL images. Two consumers:
- ``PluginAdapter`` trims the blank margins plugins bake into their content
before it enters the ticker (see ``trim_to_content``).
- ``scripts/dev/vegas_audit.py`` reports how much of the composed ticker is
dead space (see ``dead_window_stats``).
Keeping both on the same primitives means the number the audit reports is the
number the trimmer acted on.
All column scans go through numpy: a Python-level per-column loop over a
17,000px-wide ticker image takes seconds, which is far too slow for the render
path.
"""
from typing import List, NamedTuple, Optional, Tuple
import numpy as np
from PIL import Image
# A pixel counts as "ink" when any channel exceeds this. Chosen to ignore the
# 1-2/255 noise that JPEG-sourced logos and alpha compositing leave behind in
# nominally black areas, while still treating any deliberately drawn dark grey
# as real content.
DEFAULT_INK_THRESHOLD = 10
# A window counts as "dead" when this fraction of its columns carry no ink.
DEFAULT_DEAD_WINDOW_RATIO = 0.95
def column_has_ink(img: Image.Image, threshold: int = DEFAULT_INK_THRESHOLD) -> np.ndarray:
"""
Return a boolean array, one entry per image column, True where the column
contains at least one pixel brighter than ``threshold`` in any channel.
Args:
img: Image to scan (converted to RGB internally)
threshold: Per-channel value a pixel must exceed to count as ink
Returns:
Bool array of shape (width,)
"""
arr = np.asarray(img if img.mode == 'RGB' else img.convert('RGB'))
if arr.ndim != 3:
# Degenerate/empty image — treat every column as blank.
return np.zeros(img.width, dtype=bool)
# Collapse rows and channels: a column is ink if any pixel in it is bright.
return arr.max(axis=(0, 2)) > threshold
def content_bounds(
img: Image.Image, threshold: int = DEFAULT_INK_THRESHOLD
) -> Optional[Tuple[int, int]]:
"""
Find the first and last columns containing ink.
Args:
img: Image to measure
threshold: Ink threshold
Returns:
(first_col, last_col) inclusive, or None if the image is entirely blank
"""
ink = column_has_ink(img, threshold)
if not ink.any():
return None
first = int(ink.argmax())
last = len(ink) - 1 - int(ink[::-1].argmax())
return first, last
class TrimResult(NamedTuple):
"""Outcome of a ``trim_to_content`` call."""
image: Optional[Image.Image] # None when the source was entirely blank
original_width: int
trimmed_left: int
trimmed_right: int
@property
def is_blank(self) -> bool:
"""True when the source image carried no ink at all."""
return self.image is None
@property
def width(self) -> int:
"""Width after trimming (0 for a blank source)."""
return 0 if self.image is None else self.image.width
@property
def removed(self) -> int:
"""Total columns removed."""
return self.trimmed_left + self.trimmed_right
def trim_to_content(
img: Image.Image,
threshold: int = DEFAULT_INK_THRESHOLD,
padding: int = 0,
) -> TrimResult:
"""
Crop blank columns off the left and right edges of an image.
Only the outer edges are considered. Blank columns *between* two pieces of
content are deliberately preserved those are the plugin's own layout
(e.g. a logo on the left and a score on the right), and closing them up
would corrupt the design rather than reclaim dead space.
A plugin drawing on a non-black background is unaffected: every column of a
filled background carries ink, so there is nothing to trim.
Args:
img: Image to trim
threshold: Ink threshold
padding: Columns of the original blank margin to keep on each side, as
breathing room. Capped at what the margin actually contains, so
this never widens the image beyond its original bounds.
Returns:
TrimResult. When the image is entirely blank, ``image`` is None and the
caller decides whether to skip the plugin.
"""
bounds = content_bounds(img, threshold)
if bounds is None:
return TrimResult(None, img.width, 0, 0)
first, last = bounds
pad = max(0, padding)
left = max(0, first - pad)
right = min(img.width, last + 1 + pad)
if left == 0 and right == img.width:
return TrimResult(img, img.width, 0, 0)
cropped = img.crop((left, 0, right, img.height))
return TrimResult(cropped, img.width, left, img.width - right)
def edge_blank(
img: Image.Image, threshold: int = DEFAULT_INK_THRESHOLD
) -> Tuple[int, int]:
"""
Blank column counts at the left and right edges of an image.
Used to space items by *measured* separation rather than a flat added gap.
A fixed gap gets this wrong in both directions at once: card-style content
drawn flush to its own edges ends up nearly touching its neighbour, while
content that already carries wide margins gets pushed even further apart.
Args:
img: Image to measure
threshold: Ink threshold
Returns:
(left_blank, right_blank). For an entirely blank image both are the
full width, since there is no ink to be close to.
"""
bounds = content_bounds(img, threshold)
if bounds is None:
return img.width, img.width
first, last = bounds
return first, img.width - 1 - last
def separation_gap(
left_img: Image.Image,
right_img: Image.Image,
target: int,
minimum: int = 0,
threshold: int = DEFAULT_INK_THRESHOLD,
) -> int:
"""
Columns to insert between two images so their ink is ``target`` apart.
Only the shortfall is added: if the two images already carry enough blank
at the facing edges, nothing (beyond ``minimum``) is inserted.
Args:
left_img: Image on the left
right_img: Image on the right
target: Desired blank columns between the two pieces of ink
minimum: Floor applied regardless of what the images already have
threshold: Ink threshold
Returns:
Number of columns to insert, never negative
"""
existing = edge_blank(left_img, threshold)[1] + edge_blank(right_img, threshold)[0]
return max(minimum, target - existing, 0)
def blank_runs(
img: Image.Image,
min_run: int,
threshold: int = DEFAULT_INK_THRESHOLD,
) -> List[Tuple[int, int]]:
"""
Find maximal runs of blank columns at least ``min_run`` wide.
Distinguishes item boundaries from letter spacing. Measured on real
rendered text, the gaps *between characters* are a single column, while the
gaps a plugin puts *between items* are 8px and up (the stocks ticker uses
32px, baseball 48px). Treating any blank column as a cut point therefore
slices words in half; requiring a run excludes letter spacing.
Args:
img: Image to scan
min_run: Minimum consecutive blank columns to qualify
threshold: Ink threshold
Returns:
List of (start, end) half-open column ranges, in left-to-right order
"""
blank = ~column_has_ink(img, threshold)
if not blank.any():
return []
# Vectorised run detection: pad with False so runs touching either edge get
# a boundary, then read starts and ends off the first difference. A Python
# loop here would be far too slow on a 17,000px ticker strip.
padded = np.concatenate(([False], blank, [False]))
diff = np.diff(padded.astype(np.int8))
starts = np.flatnonzero(diff == 1)
ends = np.flatnonzero(diff == -1)
long_enough = (ends - starts) >= max(1, min_run)
return list(zip(starts[long_enough].tolist(), ends[long_enough].tolist()))
def find_item_boundary(
img: Image.Image,
target: int,
min_run: int,
threshold: int = DEFAULT_INK_THRESHOLD,
) -> Optional[int]:
"""
Find the column nearest ``target`` that sits inside a gap between items.
Used to narrow an oversized segment without cutting through a word. Only
runs of at least ``min_run`` blank columns are considered, so the
single-column gaps between characters are never chosen cutting there
orphaned the tail of a word into the following cycle, which is how a lone
"y" from "Wednesday" ended up floating between two unrelated plugins.
Args:
img: Image to cut
target: Preferred cut column
min_run: Minimum blank-run width that counts as an item boundary
threshold: Ink threshold
Returns:
A column inside a qualifying gap, or None when the image has no such
gap at all in which case the caller must not cut it.
"""
runs = blank_runs(img, min_run, threshold)
if not runs:
return None
# Nearest point of the nearest run. For a run left of target that is its
# end (content resumes just after), for a run right of target its start
# (content stopped just before) — the right choice in both directions.
def clamp_to_run(run: Tuple[int, int]) -> int:
start, end = run
return max(start, min(target, end - 1))
return min((clamp_to_run(r) for r in runs), key=lambda c: abs(c - target))
def find_blank_cut(
img: Image.Image,
target: int,
search_radius: int,
threshold: int = DEFAULT_INK_THRESHOLD,
) -> int:
"""
Find a column near ``target`` that carries no ink, so an image can be cut
there without slicing through a glyph or logo.
Used when a single oversized segment has to be narrowed to fit a width
budget. Cutting at an arbitrary column would leave half a character
hanging at the panel edge; snapping to the nearest gap hides the cut.
Args:
img: Image to cut
target: Preferred cut column
search_radius: How far either side of ``target`` to look
threshold: Ink threshold
Returns:
A blank column within the search window, or ``target`` clamped to the
image bounds when the window contains no blank column at all.
"""
width = img.width
target = max(0, min(target, width))
if search_radius <= 0 or width == 0:
return target
ink = column_has_ink(img, threshold)
# target may legitimately equal width (a cut after the last column), but
# there is no column to inspect there, so both bounds stop at width - 1.
lo = max(0, min(target - search_radius, width - 1))
hi = max(0, min(target + search_radius, width - 1))
# Walk outwards from target so the nearest gap wins.
for offset in range(0, search_radius + 1):
right = target + offset
if lo <= right <= hi and not ink[right]:
return right
left = target - offset
if lo <= left <= hi and not ink[left]:
return left
return target
class DeadWindowStats(NamedTuple):
"""How much of a composed ticker reads as blank to a viewer."""
total_windows: int
dead_windows: int
longest_dead_run: int # consecutive dead windows (i.e. scroll steps)
@property
def dead_ratio(self) -> float:
"""Fraction of viewport positions that are effectively blank."""
if self.total_windows <= 0:
return 0.0
return self.dead_windows / self.total_windows
def dead_window_stats(
img: Image.Image,
viewport_width: int,
threshold: int = DEFAULT_INK_THRESHOLD,
dead_ratio: float = DEFAULT_DEAD_WINDOW_RATIO,
step: int = 1,
) -> DeadWindowStats:
"""
Slide a viewport across a composed ticker image and count how many
positions are effectively blank.
This models what the viewer actually experiences: the ticker is only ever
seen ``viewport_width`` columns at a time, so a stretch of blank wider than
the viewport becomes a period where the panel looks switched off. Measuring
per-window rather than per-column is what makes the result correspond to
perceived dead time.
Args:
img: Composed ticker image
viewport_width: Display width in pixels
threshold: Ink threshold
dead_ratio: Fraction of blank columns for a window to count as dead
step: Column stride between sampled windows. 1 is exact; larger values
trade precision for speed on very wide images.
Returns:
DeadWindowStats. ``longest_dead_run`` is in units of ``step`` columns,
so multiply by ``step`` for pixels.
"""
if viewport_width <= 0 or img.width <= 0:
return DeadWindowStats(0, 0, 0)
ink = column_has_ink(img, threshold)
step = max(1, step)
# Prefix sum of ink counts lets each window be evaluated in constant time,
# instead of re-summing viewport_width columns per position.
prefix = np.concatenate(([0], np.cumsum(ink)))
# Only whole windows are sampled; a partial tail window would report
# artificially dead because it has fewer columns to draw ink from.
last_start = img.width - viewport_width
if last_start < 0:
# Image narrower than the viewport — evaluate it as a single window.
blank_cols = len(ink) - int(prefix[-1])
is_dead = blank_cols >= dead_ratio * len(ink)
return DeadWindowStats(1, 1 if is_dead else 0, 1 if is_dead else 0)
starts = np.arange(0, last_start + 1, step)
ink_counts = prefix[starts + viewport_width] - prefix[starts]
blank_counts = viewport_width - ink_counts
dead = blank_counts >= dead_ratio * viewport_width
longest = _longest_true_run(dead)
return DeadWindowStats(len(starts), int(dead.sum()), longest)
class CoverageStats(NamedTuple):
"""How well-filled the viewport stays as the ticker scrolls past."""
total_windows: int
mean_ink_ratio: float # average fraction of the viewport carrying ink
min_ink_ratio: float # worst viewport position in the cycle
sparse_windows: int # positions below the "looks empty" threshold
longest_sparse_run: int # consecutive sparse positions, in steps
@property
def sparse_ratio(self) -> float:
"""Fraction of viewport positions that read as near-empty."""
if self.total_windows <= 0:
return 0.0
return self.sparse_windows / self.total_windows
def window_coverage_stats(
img: Image.Image,
viewport_width: int,
threshold: int = DEFAULT_INK_THRESHOLD,
sparse_ink_ratio: float = 0.10,
step: int = 1,
) -> CoverageStats:
"""
Measure how full the viewport stays across a whole scroll cycle.
``dead_window_stats`` only catches viewport positions that are *entirely*
blank. That misses the more common complaint: a position holding one narrow
sliver of content at the very edge, with the other 90% black. Such a
position is not "dead" by that definition but still looks switched off.
This function grades every position by how much ink it carries, so
"there is always something to see" becomes measurable.
Args:
img: Composed ticker image
viewport_width: Display width in pixels
threshold: Ink threshold
sparse_ink_ratio: A position with less than this fraction of inked
columns counts as reading near-empty
step: Column stride between sampled positions
Returns:
CoverageStats
"""
if viewport_width <= 0 or img.width <= 0:
return CoverageStats(0, 0.0, 0.0, 0, 0)
ink = column_has_ink(img, threshold)
step = max(1, step)
prefix = np.concatenate(([0], np.cumsum(ink)))
last_start = img.width - viewport_width
if last_start < 0:
ratio = float(prefix[-1]) / viewport_width
sparse = ratio < sparse_ink_ratio
return CoverageStats(1, ratio, ratio, 1 if sparse else 0, 1 if sparse else 0)
starts = np.arange(0, last_start + 1, step)
ratios = (prefix[starts + viewport_width] - prefix[starts]) / viewport_width
sparse_flags = ratios < sparse_ink_ratio
return CoverageStats(
total_windows=len(starts),
mean_ink_ratio=float(ratios.mean()),
min_ink_ratio=float(ratios.min()),
sparse_windows=int(sparse_flags.sum()),
longest_sparse_run=_longest_true_run(sparse_flags),
)
def _longest_true_run(flags: np.ndarray) -> int:
"""Length of the longest consecutive run of True in a boolean array."""
if flags.size == 0 or not flags.any():
return 0
# Reset a running counter at every False by subtracting the cumulative max
# of the counter's value at the preceding False positions.
idx = np.arange(len(flags))
not_flag = ~flags
# For each position, the index of the most recent False at or before it.
last_false = np.maximum.accumulate(np.where(not_flag, idx, -1))
run_lengths = idx - last_false
return int(run_lengths[flags].max())
+535 -16
View File
@@ -8,9 +8,16 @@ implement get_vegas_content() and fallback capture of display() output.
import logging
import threading
import time
from contextlib import nullcontext
from typing import Optional, List, Any, Tuple, Union, TYPE_CHECKING
from PIL import Image
from src.vegas_mode.geometry import (
blank_runs,
separation_gap,
trim_to_content,
)
if TYPE_CHECKING:
from src.plugin_system.base_plugin import BasePlugin
@@ -26,14 +33,21 @@ class PluginAdapter:
2. Fallback: Capture display_manager.image after calling plugin.display()
"""
def __init__(self, display_manager: Any):
def __init__(self, display_manager: Any, config: Optional[Any] = None):
"""
Initialize the plugin adapter.
Args:
display_manager: DisplayManager instance for fallback capture
config: VegasModeConfig controlling trim behaviour. When omitted,
trimming runs with the dataclass defaults, so existing callers
and tests keep working unchanged.
"""
self.display_manager = display_manager
if config is None:
from src.vegas_mode.config import VegasModeConfig
config = VegasModeConfig()
self.config = config
# Handle both property and method access patterns
self.display_width = (
display_manager.width() if callable(display_manager.width)
@@ -49,12 +63,18 @@ class PluginAdapter:
self._cache_lock = threading.Lock()
self._cache_ttl = 5.0 # Cache for 5 seconds
# Per-plugin rotation offset, so a plugin whose content exceeds its
# width budget shows a different slice on each cycle rather than
# always the same opening items.
self._item_offsets: dict = {}
logger.info(
"PluginAdapter initialized: display=%dx%d",
self.display_width, self.display_height
)
def get_content(self, plugin: 'BasePlugin', plugin_id: str) -> Optional[List[Image.Image]]:
def get_content(self, plugin: 'BasePlugin', plugin_id: str,
offscreen_only: bool = False) -> Optional[List[Image.Image]]:
"""
Get scrollable content from a plugin.
@@ -63,6 +83,13 @@ class PluginAdapter:
Args:
plugin: Plugin instance to get content from
plugin_id: Plugin identifier for logging
offscreen_only: Skip every path that touches the shared display
canvas, for callers running off the render thread. The canvas
and the matrix proxy are process-wide mutable state, so
narrowing or capturing through them from another thread would
corrupt the frame the render loop is pushing. Returns None when
the plugin can only be served that way, leaving the caller to
fetch it on the render thread.
Returns:
List of PIL Images representing plugin content, or None if no content
@@ -86,32 +113,38 @@ class PluginAdapter:
has_native = hasattr(plugin, 'get_vegas_content')
logger.info("[%s] Has get_vegas_content: %s", plugin_id, has_native)
if has_native:
content = self._get_native_content(plugin, plugin_id)
content = self._get_native_content(plugin, plugin_id, offscreen_only)
if content:
total_width = sum(img.width for img in content)
logger.info(
"[%s] Native content SUCCESS: %d images, %dpx total",
plugin_id, len(content), total_width
)
self._cache_content(plugin_id, content)
return content
return self._finalize(content, plugin_id, 'native', plugin)
logger.info("[%s] Native content returned None", plugin_id)
# Try to get scroll_helper's cached image (for scrolling plugins like stocks/odds)
has_scroll_helper = hasattr(plugin, 'scroll_helper')
logger.info("[%s] Has scroll_helper: %s", plugin_id, has_scroll_helper)
content = self._get_scroll_helper_content(plugin, plugin_id)
content = self._get_scroll_helper_content(plugin, plugin_id, offscreen_only)
if content:
total_width = sum(img.width for img in content)
logger.info(
"[%s] ScrollHelper content SUCCESS: %d images, %dpx total",
plugin_id, len(content), total_width
)
self._cache_content(plugin_id, content)
return content
return self._finalize(content, plugin_id, 'scroll_helper', plugin)
if has_scroll_helper:
logger.info("[%s] ScrollHelper content returned None", plugin_id)
if offscreen_only:
# Display capture needs the shared canvas; leave it to the caller.
logger.info(
"[%s] Needs display capture, deferring to the render thread",
plugin_id
)
return None
# Fall back to display capture
logger.info("[%s] Trying fallback display capture...", plugin_id)
content = self._capture_display_content(plugin, plugin_id)
@@ -121,8 +154,7 @@ class PluginAdapter:
"[%s] Fallback capture SUCCESS: %d images, %dpx total",
plugin_id, len(content), total_width
)
self._cache_content(plugin_id, content)
return content
return self._finalize(content, plugin_id, 'fallback', plugin)
logger.warning(
"[%s] NO CONTENT from any method (native=%s, scroll_helper=%s, fallback=tried)",
@@ -130,8 +162,397 @@ class PluginAdapter:
)
return None
def _finalize(
self, images: List[Image.Image], plugin_id: str, source: str,
plugin: Optional['BasePlugin'] = None
) -> Optional[List[Image.Image]]:
"""
Trim dead space off a segment, then cache it.
Every content path funnels through here so trimming is applied
uniformly. Previously only the scroll_helper path had its margins
stripped, which left plugins that render onto a full-display canvas
contributing their entire blank canvas to the ticker.
Each image is trimmed independently because compose_scroll_content()
treats every image as its own item and inserts separator_width between
them so a per-image trim is what makes that separator the real gap.
Args:
images: Raw content from one of the fetch paths
plugin_id: Plugin identifier for logging
source: Which path produced the content, for logging
Returns:
Trimmed image list, or None if nothing worth showing remains
"""
if not self.config.auto_trim:
# Trimming is off, but the width budget is a separate concern —
# turning off margin cropping should not let one plugin hold the
# panel for minutes. Skipping it here previously let a 14,848px
# segment through untouched.
kept = self._apply_width_budget(list(images), plugin_id, plugin)
self._cache_content(plugin_id, kept)
return kept
original_width = sum(img.width for img in images)
kept: List[Image.Image] = []
dropped_blank = 0
for img in images:
result = trim_to_content(
img,
threshold=self.config.trim_threshold,
padding=self.config.content_padding,
)
if result.is_blank:
dropped_blank += 1
continue
kept.append(result.image)
if not kept:
logger.info(
"[%s] All %d image(s) from %s were blank — contributing nothing",
plugin_id, len(images), source
)
return None
trimmed_width = sum(img.width for img in kept)
if trimmed_width < self.config.min_plugin_width:
logger.info(
"[%s] Trimmed content %dpx is below min_plugin_width %dpx — skipping",
plugin_id, trimmed_width, self.config.min_plugin_width
)
return None
if trimmed_width != original_width or dropped_blank:
logger.info(
"[%s] Trimmed %s content: %dpx -> %dpx (%.0f%% reclaimed), "
"%d image(s) kept, %d blank dropped",
plugin_id, source, original_width, trimmed_width,
100.0 * (original_width - trimmed_width) / original_width
if original_width else 0.0,
len(kept), dropped_blank
)
kept = self._apply_width_budget(kept, plugin_id, plugin)
self._cache_content(plugin_id, kept)
return kept
def _capture(self):
"""
Context manager suppressing hardware writes while plugin render code runs.
Degrades to a no-op when the display manager predates capture_mode. As
with _render_at, losing the suppression risks a visible flash, whereas
raising would be swallowed by the broad handlers upstream and drop the
plugin's content entirely — much worse.
"""
capture_mode = getattr(self.display_manager, 'capture_mode', None)
if capture_mode is None:
logger.debug(
"display_manager has no capture_mode(); plugin writes during "
"content capture may reach the panel"
)
return nullcontext()
return capture_mode()
def _render_at(self, width: int):
"""
Context manager narrowing the plugin-facing canvas to ``width``.
Degrades to a no-op when the display manager predates render_size (a
third-party or older test harness). Losing the narrowing is a cosmetic
regression; raising here would be caught by the broad handlers upstream
and silently drop the plugin's content entirely.
"""
render_size = getattr(self.display_manager, 'render_size', None)
if render_size is None:
logger.debug(
"display_manager has no render_size(); Vegas width requests "
"will be ignored"
)
return nullcontext()
return render_size(width)
def resolve_render_width(self, plugin: 'BasePlugin', plugin_id: str) -> int:
"""
Width to tell a plugin it has while it renders for the ticker.
Resolution order, most specific first:
1. the plugin's own ``vegas_width_pct`` config value
2. the global ``vegas_scroll.render_width_pct``
3. the full panel width
A percentage rather than an absolute width so one setting travels
across panel sizes.
Args:
plugin: Plugin instance, consulted for a per-plugin override
plugin_id: Plugin identifier for logging
Returns:
Target width in pixels, never wider than the panel
"""
pct = self.config.render_width_pct
plugin_cfg = getattr(plugin, 'config', None)
if isinstance(plugin_cfg, dict):
raw = plugin_cfg.get('vegas_width_pct')
if raw not in (None, ''):
try:
candidate = int(raw)
except (TypeError, ValueError):
logger.warning(
"[%s] Invalid vegas_width_pct %r, ignoring", plugin_id, raw)
else:
if 10 <= candidate <= 100:
pct = candidate
else:
logger.warning(
"[%s] vegas_width_pct %d out of range 10-100, ignoring",
plugin_id, candidate)
if pct >= 100:
return self.display_width
return max(1, int(self.display_width * pct / 100))
def _row_gap(self, left: Image.Image, right: Image.Image) -> int:
"""
Gap the compositor will insert between two of a plugin's rows.
Mirrors RenderPipeline._join_plugin_rows so the width budget measures
what will actually be rendered.
"""
return separation_gap(
left, right,
target=max(0, self.config.min_content_separation),
minimum=max(0, self.config.intra_plugin_gap),
threshold=self.config.trim_threshold,
)
def _plugin_setting(self, plugin: 'BasePlugin', key: str):
"""Read a per-plugin config override, or None if absent."""
plugin_cfg = getattr(plugin, 'config', None)
if not isinstance(plugin_cfg, dict):
return None
value = plugin_cfg.get(key)
return None if value in (None, '') else value
def resolve_overflow_mode(self, plugin: 'BasePlugin', plugin_id: str) -> str:
"""
How to handle content that exceeds this plugin's width budget.
'rotate' advances a window each cycle so everything is seen eventually,
which suits interchangeable items. 'truncate' always shows the start,
which suits ordered content a league table that shows ranks 1-6 and
then resumes at 7 two rotations later reads as out of order, and nobody
needs rank 23 in a ticker anyway.
Per-plugin ``vegas_overflow`` wins over the global ``overflow_mode``.
"""
raw = self._plugin_setting(plugin, 'vegas_overflow')
if raw is not None:
candidate = str(raw).strip().lower()
if candidate in ('rotate', 'truncate'):
return candidate
logger.warning(
"[%s] Invalid vegas_overflow %r, expected 'rotate' or 'truncate'",
plugin_id, raw
)
return self.config.overflow_mode
def _width_budget(self, plugin: Optional['BasePlugin'] = None,
plugin_id: str = '') -> int:
"""
Maximum columns one plugin may occupy in a cycle. 0 means unlimited.
A per-plugin ``vegas_max_width_screens`` overrides the global ratio, so
content that has to stay whole can be given room (or uncapped with 0)
without lifting the cap on every ticker.
"""
ratio = self.config.max_plugin_width_ratio
if plugin is not None:
raw = self._plugin_setting(plugin, 'vegas_max_width_screens')
if raw is not None:
try:
candidate = float(raw)
except (TypeError, ValueError):
logger.warning(
"[%s] Invalid vegas_max_width_screens %r, ignoring",
plugin_id, raw
)
else:
if candidate >= 0:
ratio = candidate
else:
logger.warning(
"[%s] vegas_max_width_screens must be >= 0, got %s",
plugin_id, candidate
)
if ratio <= 0:
return 0
return int(self.display_width * ratio)
def _apply_width_budget(
self, images: List[Image.Image], plugin_id: str,
plugin: Optional['BasePlugin'] = None
) -> List[Image.Image]:
"""
Hold one plugin to its share of a cycle.
A ticker returning 7,000px would otherwise own the panel for over two
minutes, which defeats the point of a rotation. Overflow is deferred
rather than discarded: the starting offset advances each time this
plugin is fetched, so later items appear on subsequent cycles instead
of never being seen.
Args:
images: Trimmed images for this plugin
plugin_id: Plugin identifier, used to track its rotation offset
Returns:
Images that fit the budget, starting from the plugin's current
rotation offset.
"""
budget = self._width_budget(plugin, plugin_id)
mode = (self.resolve_overflow_mode(plugin, plugin_id)
if plugin is not None else self.config.overflow_mode)
# Count the gaps the compositor will actually insert, not just the
# pixels of the rows — otherwise a plugin with many rows quietly
# occupies far more of the panel than its budget allows. These must use
# the same measured rule as RenderPipeline._join_plugin_rows; assuming
# the flat intra_plugin_gap here under-counted by up to
# (min_content_separation - intra_plugin_gap) per row.
total = sum(img.width for img in images) + sum(
self._row_gap(images[i], images[i + 1]) for i in range(len(images) - 1)
)
if not budget or total <= budget:
# Fits, so reset rotation — the whole segment is being shown.
self._item_offsets.pop(plugin_id, None)
return images
if len(images) == 1:
return [self._crop_to_budget(images[0], budget, plugin_id, mode)]
if mode == 'truncate':
# Ordered content: always show from the top. Deliberately does not
# advance the offset, so the same opening items appear every time
# rather than the viewer being shown the middle of a ranked list.
start = 0
else:
start = self._item_offsets.get(plugin_id, 0) % len(images)
selected: List[Image.Image] = []
used = 0
consumed = 0
# Walk forward from the rotation offset, taking whole items only, so a
# cut never lands in the middle of one.
for step in range(len(images)):
img = images[(start + step) % len(images)]
cost = img.width
if selected:
cost += self._row_gap(selected[-1], img)
if selected and used + cost > budget:
break
selected.append(img)
used += cost
consumed += 1
if mode == 'truncate':
logger.info(
"[%s] Width budget %dpx: showing the first %d of %d row(s) "
"(%dpx incl. gaps); the rest are not shown (overflow=truncate)",
plugin_id, budget, len(selected), len(images), used
)
else:
self._item_offsets[plugin_id] = (start + consumed) % len(images)
logger.info(
"[%s] Width budget %dpx: showing %d of %d row(s) (%dpx incl. gaps) "
"from offset %d; remainder deferred to a later cycle",
plugin_id, budget, len(selected), len(images), used, start
)
return selected
def _crop_to_budget(
self, img: Image.Image, budget: int, plugin_id: str,
mode: str = 'rotate'
) -> Image.Image:
"""
Narrow a single oversized image to the budget, advancing a window
through it across cycles.
The cut is snapped to the nearest blank column so it does not slice
through a glyph or logo and leave half a character at the panel edge.
"""
if mode == 'truncate':
# Always the start of the strip, so a ranked table is never entered
# from the middle.
offset = 0
else:
offset = self._item_offsets.get(plugin_id, 0)
if offset >= img.width:
offset = 0
# Cut only where the plugin left a real gap between items. Snapping to
# any blank column used to pick the single-column gaps between
# characters, splitting a word and orphaning its tail into the next
# cycle — a lone "y" from "Wednesday" floating between two unrelated
# plugins. Overshooting the budget is the lesser evil.
min_run = max(2, self.config.min_cut_gap)
gaps = blank_runs(img, min_run, self.config.trim_threshold)
if not gaps:
# No internal gaps means continuous content — a map, a chart, a
# photo — where any column is as good as any other, so cut to the
# budget exactly. The gap rule exists to protect discrete items
# (words, ticker entries); it would be wrong to let a solid image
# escape the cap in its name.
end = min(offset + budget, img.width)
if mode != 'truncate':
self._item_offsets[plugin_id] = 0 if end >= img.width else end
logger.info(
"[%s] Width budget %dpx: cropped continuous %dpx image to "
"[%d:%d] (no item gaps of %dpx+ to align to)%s",
plugin_id, budget, img.width, offset, end, min_run,
"" if mode != 'truncate' else "; showing the start only"
)
return img.crop((offset, 0, end, img.height))
# Cut mid-gap so the content either side keeps some breathing room.
cuts = sorted({0, img.width} | {(a + b) // 2 for a, b in gaps})
start = max((c for c in cuts if c <= offset), default=0)
later = [c for c in cuts if c > start]
if not later:
end = img.width
else:
within = [c for c in later if c <= start + budget]
# No boundary inside the budget: take the next one and overrun,
# because the alternative is cutting through an item.
end = max(within) if within else min(later)
if mode != 'truncate':
# Next cycle resumes where this one stopped; wrap when the strip ends.
self._item_offsets[plugin_id] = 0 if end >= img.width else end
logger.info(
"[%s] Width budget %dpx: cropped single %dpx image to [%d:%d] "
"(%dpx) at item boundaries, %s",
plugin_id, budget, img.width, start, end, end - start,
"showing the start only (overflow=truncate)"
if mode == 'truncate' else "window advances next cycle"
)
return img.crop((start, 0, end, img.height))
def _get_native_content(
self, plugin: 'BasePlugin', plugin_id: str
self, plugin: 'BasePlugin', plugin_id: str, offscreen_only: bool = False
) -> Optional[List[Image.Image]]:
"""
Get content via plugin's native get_vegas_content() method.
@@ -145,7 +566,40 @@ class PluginAdapter:
"""
try:
logger.info("[%s] Native: calling get_vegas_content()", plugin_id)
result = plugin.get_vegas_content()
# Tell the plugin how much width the ticker wants it to use, and
# narrow the canvas for the duration of the call. A plugin that
# sizes its own images from display_manager.matrix.width picks up
# the narrower value with no changes of its own; one that wants to
# be explicit can read get_vegas_render_width().
render_width = self.resolve_render_width(plugin, plugin_id)
if render_width != self.display_width:
logger.info(
"[%s] Native: requesting %dpx instead of %dpx",
plugin_id, render_width, self.display_width
)
plugin._vegas_render_width = render_width
try:
# capture_mode unconditionally, even at full width. Building
# Vegas content is an off-screen operation, but a plugin is free
# to call update_display() while doing it — and outside
# capture_mode that write lands on the hardware, flashing the
# panel mid-scroll. The narrowing context is separate because it
# is a no-op at full width.
if offscreen_only:
# _render_at swaps the shared canvas, so it is unsafe here.
# _vegas_render_width is set regardless: a plugin reading
# get_vegas_render_width() still gets its narrow size, and
# one that only reads matrix.width renders full width and is
# trimmed instead.
with self._capture():
result = plugin.get_vegas_content()
else:
with self._capture(), self._render_at(render_width):
result = plugin.get_vegas_content()
finally:
plugin._vegas_render_width = None
if result is None:
logger.info("[%s] Native: get_vegas_content() returned None", plugin_id)
@@ -223,7 +677,7 @@ class PluginAdapter:
return None
def _get_scroll_helper_content(
self, plugin: 'BasePlugin', plugin_id: str
self, plugin: 'BasePlugin', plugin_id: str, offscreen_only: bool = False
) -> Optional[List[Image.Image]]:
"""
Get content from plugin's scroll_helper if available.
@@ -257,6 +711,13 @@ class PluginAdapter:
"[%s] scroll_helper.cached_image is None, triggering content generation",
plugin_id
)
if offscreen_only:
# Generating it calls display(), which needs the canvas.
logger.info(
"[%s] scroll_helper cache empty; deferring generation "
"to the render thread", plugin_id
)
return None
# Try to trigger scroll content generation
cached_image = self._trigger_scroll_content_generation(
plugin, plugin_id, scroll_helper
@@ -405,7 +866,7 @@ class PluginAdapter:
# Save display state to restore after
original_image = self.display_manager.image.copy()
with self.display_manager.capture_mode():
with self._capture():
# Method 1: Try _create_scrolling_display (stocks pattern)
if hasattr(plugin, '_create_scrolling_display'):
logger.info(
@@ -497,7 +958,18 @@ class PluginAdapter:
# Clear and call plugin display — use capture_mode to suppress hardware writes
# that plugins may trigger internally via update_display().
with self.display_manager.capture_mode():
#
# render_size narrows the canvas the plugin lays out against, so a
# plugin that spreads across the whole panel produces a compact
# arrangement rather than one that has to be cropped afterwards.
render_width = self.resolve_render_width(plugin, plugin_id)
if render_width != self.display_width:
logger.info(
"[%s] Fallback: rendering at %dpx instead of %dpx",
plugin_id, render_width, self.display_width
)
with self._capture(), self._render_at(render_width):
self.display_manager.clear()
logger.info("[%s] Fallback: display cleared, calling display()", plugin_id)
@@ -531,7 +1003,7 @@ class PluginAdapter:
plugin_id
)
# Try once more with force_clear=True
with self.display_manager.capture_mode():
with self._capture(), self._render_at(render_width):
self.display_manager.clear()
plugin.display(force_clear=True)
captured = self.display_manager.image.copy()
@@ -663,6 +1135,53 @@ class PluginAdapter:
else:
self._content_cache.clear()
def invalidate_plugin_scroll_cache(
self, plugin: 'BasePlugin', plugin_id: str
) -> bool:
"""
Drop a plugin's own cached scroll image so its visual is rebuilt.
Invalidating only this adapter's cache is not enough. A plugin that
composes a scroll strip hands back the *same* image every time until its
own cache is cleared the sports plugins' ``get_vegas_content()``
regenerates only "if the cache is empty" so without this a segment
keeps rendering whatever data it was first built from. That is how a
game that was live last night can still be displayed as live the next
morning.
Two layouts to cover: a helper directly on the plugin (stocks, news,
odds-ticker) and one owned by a scroll-display manager (the sports
scoreboards). ``cached_image`` and ``cached_array`` must be cleared
together, since the array is the image's numpy mirror and code paths
read whichever is convenient.
Returns:
True if a cache was found and cleared.
"""
cleared = False
for owner in (plugin, getattr(plugin, '_scroll_manager', None),
getattr(plugin, 'scroll_manager', None)):
if owner is None:
continue
helper = getattr(owner, 'scroll_helper', None)
if helper is None:
continue
try:
if getattr(helper, 'cached_image', None) is not None:
helper.cached_image = None
cleared = True
if getattr(helper, 'cached_array', None) is not None:
helper.cached_array = None
cleared = True
except Exception: # pylint: disable=broad-except
logger.exception(
"[%s] Could not clear scroll cache on %s",
plugin_id, type(owner).__name__
)
if cleared:
logger.debug("[%s] Cleared plugin scroll cache", plugin_id)
return cleared
def get_content_type(self, plugin: 'BasePlugin', plugin_id: str) -> str:
"""
Get the type of content a plugin provides.
+365 -52
View File
@@ -6,6 +6,7 @@ Uses the existing ScrollHelper for numpy-optimized scroll operations.
"""
import logging
import os
import time
import threading
from collections import deque
@@ -14,6 +15,7 @@ from PIL import Image
from src.common.scroll_helper import ScrollHelper
from src.vegas_mode.config import VegasModeConfig
from src.vegas_mode.geometry import separation_gap
from src.vegas_mode.stream_manager import StreamManager
if TYPE_CHECKING:
@@ -34,6 +36,10 @@ class RenderPipeline:
- Track scroll cycle completion
"""
# Minimum gap between fetches of canvas-bound plugins, so their individual
# stalls land in separate moments rather than one run of hitches.
DEFERRED_DRAIN_INTERVAL = 2.0
def __init__(
self,
config: VegasModeConfig,
@@ -66,10 +72,6 @@ class RenderPipeline:
else display_manager.height
)
# Reusable blank frame for cycle-end pushes (allocated lazily,
# re-blacked before each reuse)
self._blank_frame = None
# ScrollHelper for optimized scrolling
self.scroll_helper = ScrollHelper(
self.display_width,
@@ -85,6 +87,14 @@ class RenderPipeline:
self._staging_scroll_image: Optional[Image.Image] = None
self._buffer_lock = threading.Lock()
# Group prepared off the render thread, waiting to be appended.
self._prepared_group = None
# Plugins that need the shared canvas, appended one at a time.
self._deferred_queue: List[str] = []
self._last_drain_time = 0.0
self._prefetch_thread: Optional[threading.Thread] = None
self._prefetch_lock = threading.Lock()
# Render state
self._is_rendering = False
self._cycle_complete = False
@@ -114,6 +124,7 @@ class RenderPipeline:
"""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_sub_pixel_scrolling(self.config.smooth_scroll)
# Config scroll_speed is always pixels per second, but ScrollHelper
# interprets it differently based on frame_based_scrolling mode:
@@ -141,23 +152,37 @@ class RenderPipeline:
True if composition successful
"""
try:
# Get all buffered content
images = self.stream_manager.get_all_content_for_composition()
# Content grouped by plugin, so a separator can be placed at the
# plugin boundaries only.
grouped = self.stream_manager.get_grouped_content_for_composition()
if not images:
if not grouped:
logger.warning("No content available for composition")
return False
# Add separator gaps between images
content_with_gaps = []
for i, img in enumerate(images):
content_with_gaps.append(img)
# Collapse each plugin's rows into a single block, joined by
# intra_plugin_gap. ScrollHelper applies one uniform gap between the
# items it is given, so handing it one item per plugin is what makes
# separator_width mean "between plugins" instead of "between every
# row". Without this, a per-row ticker such as the F1 scoreboard got
# the full separator between each of its ~116 rows.
blocks = []
total_rows = 0
for plugin_id, images in grouped:
total_rows += len(images)
blocks.append(self._join_plugin_rows(images))
# Create scrolling image via ScrollHelper
# Create scrolling image via ScrollHelper.
#
# lead_gap is explicit because ScrollHelper otherwise prepends a
# full display width of black — appropriate for a standalone ticker
# scrolling in from off-screen, but in Vegas mode it is charged
# once per cycle and reads as the panel switching off.
self.scroll_helper.create_scrolling_image(
content_items=content_with_gaps,
content_items=blocks,
item_gap=self.config.separator_width,
element_gap=0
element_gap=0,
lead_gap=self.config.lead_in_width
)
# Verify scroll image was created successfully
@@ -177,11 +202,16 @@ class RenderPipeline:
self._cycle_complete = False
logger.info(
"Composed scroll image: %dx%d, %d plugins, %d items",
"Composed scroll image: %dx%d, %d plugin block(s), %d rows, "
"separator=%dpx between plugins, rows spaced to %dpx of ink "
"(min added %dpx)",
self.scroll_helper.cached_image.width if self.scroll_helper.cached_image else 0,
self.display_height,
len(self._segments_in_scroll),
len(images)
len(blocks),
total_rows,
self.config.separator_width,
self.config.min_content_separation,
self.config.intra_plugin_gap,
)
return True
@@ -191,6 +221,264 @@ class RenderPipeline:
logger.exception("Error composing scroll content")
return False
def needs_extension(self) -> bool:
"""
Whether the strip should be extended with the next group of plugins.
Cheap enough to call every frame: it is arithmetic over cached state.
"""
if not self.config.continuous_scroll or not self.scroll_helper.cached_image:
return False
threshold = int(self.display_width * self.config.extend_threshold_screens)
return self.scroll_helper.remaining_unscrolled() <= threshold
def start_prefetch(self) -> None:
"""
Begin preparing the next group in the background, if not already doing so.
This is what makes the join seamless rather than merely continuous:
fetching a group costs 0.5-4.8s (rendering leaderboard and baseball cards
dominates), and doing it on the render thread stalls the scroll for that
long. Off the render thread there is a whole group's scroll time to work
in, so by the time the strip needs extending the content is already sat
waiting.
Only paths that avoid the shared display canvas run here; anything
needing it is marked and picked up on the render thread, where it is
safe. Those are the cheap ones display capture measured 12-14ms
against seconds for the native renders.
"""
if not self.config.continuous_scroll:
return
with self._prefetch_lock:
if self._prefetch_thread is not None and self._prefetch_thread.is_alive():
return
if self._prepared_group is not None:
return # already have one waiting
def _work():
# Deprioritise against the render loop. Linux applies nice
# per-thread, and the heavy lifting here is PIL and numpy work
# that releases the GIL, so the scheduler can actually act on
# it — without this the prefetch competes for the same cores and
# costs frames.
try:
os.nice(10)
except (OSError, AttributeError):
pass
try:
group = self.stream_manager.take_next_group(offscreen_only=True)
except Exception:
logger.exception("Background prefetch failed")
group = []
with self._prefetch_lock:
self._prepared_group = group
self._prefetch_thread = threading.Thread(
target=_work, daemon=True, name="vegas-strip-prefetch")
self._prefetch_thread.start()
def drain_deferred(self) -> bool:
"""
Fetch one queued canvas-bound plugin and append it to the strip.
Called once per frame. These plugins cannot be prepared off the render
thread display capture and scroll-content generation both need the
shared canvas so each costs roughly 290ms here. Doing one at a time
spreads that out instead of stalling for the whole group at once, and the
strip's lookahead means nothing runs dry while they arrive.
The cost is that a deferred plugin appears slightly after the group it
came with, which is a fair trade for a smooth scroll.
Returns:
True if a plugin was appended
"""
if not self._deferred_queue:
return False
# Space the drains out. Each costs 40-600ms, and taking them back to
# back turns one long stall into a train of short ones — barely better.
# With a healthy lookahead there is no hurry, so wait a beat between
# them; when the strip is actually running short, fetch immediately.
threshold = int(self.display_width * self.config.extend_threshold_screens)
urgent = self.scroll_helper.remaining_unscrolled() <= threshold
if not urgent:
now = time.time()
if now - self._last_drain_time < self.DEFERRED_DRAIN_INTERVAL:
return False
self._last_drain_time = now
else:
self._last_drain_time = time.time()
plugin_id = self._deferred_queue.pop(0)
plugins = getattr(self.stream_manager.plugin_manager, 'plugins', {})
plugin = plugins.get(plugin_id)
if plugin is None:
return False
try:
images = self.stream_manager.plugin_adapter.get_content(plugin, plugin_id)
except Exception:
logger.exception("[%s] Error fetching deferred content", plugin_id)
return False
if not images:
return False
appended = self.scroll_helper.append_content(
content_items=[self._join_plugin_rows(images)],
item_gap=self.config.separator_width,
element_gap=0,
)
if appended:
with self._buffer_lock:
self._active_scroll_image = self.scroll_helper.cached_image
logger.info(
"[%s] Appended deferred content: strip now %dpx, %dpx ahead",
plugin_id, self.scroll_helper.total_scroll_width,
self.scroll_helper.remaining_unscrolled()
)
return appended
def has_deferred(self) -> bool:
"""Whether any canvas-bound plugins are still queued."""
return bool(self._deferred_queue)
def _claim_prepared_group(self):
"""Take the prefetched group, if one is ready."""
with self._prefetch_lock:
group = self._prepared_group
self._prepared_group = None
return group
def extend_scroll_content(self) -> bool:
"""
Append the next group of plugins to the strip, without interrupting motion.
This is what replaces the swap. Scroll position is untouched, so the new
content simply arrives from the right; there is no substitution to see
and no restart with the viewport already full.
Consumed columns behind the viewport are then released, keeping the strip
bounded however long Vegas runs.
Returns:
True if the strip was extended
"""
try:
grouped = self._claim_prepared_group()
if grouped is None:
# Nothing prepared (first extension, or prefetch still running).
# Fetch inline; the scroll hitches, but content keeps flowing.
logger.info("No prepared group ready; fetching inline")
grouped = self.stream_manager.take_next_group()
if not grouped:
logger.warning("No content available to extend the scroll strip")
return False
# Plugins the background thread had to defer need the shared canvas,
# so they can only be fetched here. Queue them rather than doing all
# of them now: measured, six in one go held the render thread for
# 1.75s. They are trickled in one per frame by drain_deferred(),
# which the strip's lookahead comfortably absorbs.
deferred = [pid for pid, images in grouped if images is None]
if deferred:
self._deferred_queue.extend(deferred)
logger.info(
"Queued %d plugin(s) needing the render thread: %s",
len(deferred), ', '.join(deferred)
)
grouped = [(pid, imgs) for pid, imgs in grouped if imgs]
if not grouped:
# Everything in this group is queued; the queue will extend the
# strip as it drains, so this is not a failure.
logger.info("Whole group deferred; strip will extend as it drains")
self.start_prefetch()
return bool(deferred)
blocks = []
total_rows = 0
for _plugin_id, images in grouped:
total_rows += len(images)
blocks.append(self._join_plugin_rows(images))
appended = self.scroll_helper.append_content(
content_items=blocks,
item_gap=self.config.separator_width,
element_gap=0,
)
if not appended:
return False
# Keep a screen's worth behind the viewport as a safety margin.
self.scroll_helper.drop_scrolled_prefix(keep_before=self.display_width)
with self._buffer_lock:
self._active_scroll_image = self.scroll_helper.cached_image
self._segments_in_scroll = [pid for pid, _ in grouped]
self.stats['composition_count'] += 1
self.stats['extensions'] = self.stats.get('extensions', 0) + 1
logger.info(
"Extended scroll strip with %d plugin block(s), %d rows: "
"strip now %dpx, %dpx still ahead of the viewport",
len(blocks), total_rows, self.scroll_helper.total_scroll_width,
self.scroll_helper.remaining_unscrolled()
)
# Line up the group after this one straight away, so it is ready
# well before the strip runs short again.
self.start_prefetch()
return True
except (ValueError, TypeError, OSError, RuntimeError):
logger.exception("Error extending scroll content")
return False
def _join_plugin_rows(self, images: List[Image.Image]) -> Image.Image:
"""
Concatenate one plugin's images into a single block.
Args:
images: That plugin's content, in order
Returns:
A single image with the rows laid out left to right, separated by
``intra_plugin_gap``. Returned unchanged when there is only one row,
which is the common case and avoids a pointless copy.
"""
if len(images) == 1:
return images[0]
floor = max(0, self.config.intra_plugin_gap)
target = max(0, self.config.min_content_separation)
threshold = self.config.trim_threshold
# Space by measured separation, not a flat gap. Rows drawn flush to
# their own edges (sports score cards) would otherwise end up nearly
# touching, while rows that already carry wide margins would be pushed
# needlessly further apart.
gaps = [
separation_gap(images[i], images[i + 1], target, floor, threshold)
for i in range(len(images) - 1)
]
width = sum(img.width for img in images) + sum(gaps)
height = max(img.height for img in images)
block = Image.new('RGB', (width, height), (0, 0, 0))
x = 0
for i, img in enumerate(images):
block.paste(img, (x, 0))
x += img.width + (gaps[i] if i < len(gaps) else 0)
return block
def render_frame(self) -> bool:
"""
Render a single frame to the display.
@@ -211,21 +499,33 @@ class RenderPipeline:
# Determine if the cycle is done.
#
# scroll_helper considers a cycle complete only after
# total_distance_scrolled >= total_scroll_width + display_width.
# That extra display_width of travel causes a "wrap-around" phase
# where scroll_position resets to ~0 and the first plugin's content
# re-enters from the right — the user sees this 2-3 s of re-entry
# as "a plugin partially displaying before the next one starts."
# get_visible_portion wraps: once scroll_position + display_width
# passes the end of the strip it fills the right-hand side of the
# frame from the *head* of the same strip. So the last
# display_width of travel shows the cycle's first plugin re-entering
# on the right while its last plugin exits on the left, and the
# recompose that follows then replaces both at once. That reads as
# the ticker "switching mid-scroll".
#
# We end the cycle as soon as total_distance_scrolled reaches
# total_scroll_width (the wrap-around point), before any second-pass
# content becomes visible. The scroll_helper's own is_scroll_complete()
# check is kept as a fallback for any edge-cases where that threshold
# is never hit.
# This used to be hidden because the strip began with a full
# display_width of blank, so the wrapped-in region was black.
# lead_in_width now defaults to 0 (that blank was 10s of dead panel
# at 50px/s), which exposed the wrap — so the cycle has to end
# before it, one display width earlier.
#
# A strip no wider than the display never wraps, and subtracting
# would make the cycle complete instantly, so clamp in that case.
# In continuous mode there is no cycle to complete: the strip is
# extended before the scroll can reach its end, so the wrap is never
# entered and motion never stops. The completion path below stays for
# the swap behaviour and as a backstop if an extension fails.
wrap_point = self.scroll_helper.total_scroll_width
if wrap_point > self.display_width:
wrap_point -= self.display_width
at_wrap_point = (
not self._cycle_complete and
self.scroll_helper.total_distance_scrolled >= self.scroll_helper.total_scroll_width
self.scroll_helper.total_distance_scrolled >= wrap_point
)
if at_wrap_point or self.scroll_helper.is_scroll_complete():
@@ -236,24 +536,17 @@ class RenderPipeline:
"Scroll cycle complete after %.1fs",
time.time() - self._cycle_start_time
)
# Push blank immediately so the hardware never shows any
# post-wrap content while the coordinator recomposes the
# next cycle (~100 ms). The blank is allocated once and
# reused across cycle wraps (fresh paste each time in case
# a consumer drew on the previous one).
try:
if self._blank_frame is None or self._blank_frame.size != (
self.display_width, self.display_height):
self._blank_frame = Image.new(
'RGB', (self.display_width, self.display_height))
else:
self._blank_frame.paste(
(0, 0, 0),
(0, 0, self.display_width, self.display_height))
self.display_manager.image = self._blank_frame
self.display_manager.update_display()
except Exception:
logger.exception("Failed to write blank frame to display at cycle end")
# Deliberately leave the last rendered frame on the panel.
#
# This used to push a blank frame so no post-wrap content
# could be seen while the next cycle was composed. But
# recomposing is synchronous and fetches plugin content:
# measured 84ms at best and 4.8s at worst on a 512px panel,
# and every millisecond of it was black. Holding the last
# frame instead turns that into a brief freeze, which reads
# as far less broken than the display switching off. The
# frame is already past the end of the content, so there is
# no second-pass content to leak.
return True # Cycle done; coordinator starts new cycle next frame
# Get visible portion
@@ -336,6 +629,25 @@ class RenderPipeline:
return False
def refresh_updated_plugins(self) -> bool:
"""
Let changed plugin data reach the strip without interrupting motion.
Used instead of :meth:`hot_swap_content` when scrolling continuously.
The swap rebuilds the whole image and repositions the scroll, which is
visible as a freeze and a jump; the strip is extended here rather than
replaced, so it is enough to drop the stale caches and let the plugin
recompose when it next comes round.
Returns:
True if any plugin's cached content was dropped.
"""
try:
return bool(self.stream_manager.invalidate_pending_updates())
except Exception: # pylint: disable=broad-except
logger.exception("Failed to refresh updated plugins")
return False
def hot_swap_content(self) -> bool:
"""
Hot-swap to new composed content.
@@ -415,11 +727,12 @@ class RenderPipeline:
result = self.compose_scroll_content()
if result and self.sync_manager:
# When sync is active, start the leader at display_width instead of 0.
# This skips the initial black gap so the leader immediately shows content.
# The follower starts at position 0 (the gap) which looks like a clean
# blank transition rather than near-end content wrapping around.
self.scroll_helper.scroll_position = float(self.display_width)
# When sync is active, start the leader past the lead-in gap so it
# immediately shows content, leaving the follower on the blank gap
# for a clean transition rather than near-end content wrapping
# around. This tracks lead_in_width rather than assuming a full
# display width of gap, which is no longer the default.
self.scroll_helper.scroll_position = float(self.config.lead_in_width)
if result and self.sync_manager:
# Signal follower that a new cycle started (triggers its own rebuild)
+146 -13
View File
@@ -14,7 +14,7 @@ Supports three display modes:
import logging
import threading
import time
from typing import Optional, List, Dict, Any, Deque, TYPE_CHECKING
from typing import Optional, List, Dict, Any, Deque, Tuple, TYPE_CHECKING
from collections import deque
from dataclasses import dataclass, field
from PIL import Image
@@ -116,8 +116,11 @@ class StreamManager:
logger.warning("No plugins available for Vegas scroll")
return False
# Prefetch initial content
self._prefetch_content(count=min(self.config.buffer_ahead + 1, len(self._ordered_plugins)))
# Fill the buffer to a whole cycle's worth of plugins. This used to be
# buffer_ahead + 1, which conflated prefetch depth with cycle size and
# meant a 20-plugin install only showed 3 plugins before recomposing.
self._prefetch_content(
count=min(self.config.plugins_per_cycle, len(self._ordered_plugins)))
logger.info(
"StreamManager initialized with %d plugins, %d segments buffered",
@@ -198,6 +201,47 @@ class StreamManager:
logger.debug("Plugin %s marked for update", plugin_id)
def invalidate_pending_updates(self) -> List[str]:
"""
Drop cached content for plugins whose data changed, without refetching.
The continuous-scroll counterpart to :meth:`process_updates`. That method
belongs to the swap path: it refetches immediately and merges into the
active buffer, which continuous mode bypasses entirely, and doing that
work on the render thread would hitch the scroll.
Here it is enough to clear the caches and let the plugin come round in
the rotation, which recomposes it from current data a moment later. Left
uncalled, ``_pending_updates`` simply accumulates and no visual ever
refreshes a game that was live last night keeps being drawn as live.
Returns:
The plugin ids whose caches were dropped.
"""
with self._buffer_lock:
if not self._pending_updates:
return []
updated = list(self._pending_updates.keys())
self._pending_updates.clear()
plugins = getattr(self.plugin_manager, 'plugins', {})
for plugin_id in updated:
try:
self.plugin_adapter.invalidate_cache(plugin_id)
plugin = plugins.get(plugin_id)
if plugin is not None:
self.plugin_adapter.invalidate_plugin_scroll_cache(
plugin, plugin_id)
except Exception: # pylint: disable=broad-except
logger.exception(
"[%s] Could not invalidate cached content", plugin_id)
logger.info(
"Vegas: dropped cached content for %d updated plugin(s): %s",
len(updated), ', '.join(updated)
)
return updated
def has_pending_updates(self) -> bool:
"""Check if any plugins have pending updates awaiting processing."""
with self._buffer_lock:
@@ -385,7 +429,7 @@ class StreamManager:
return
for _ in range(count):
if len(self._active_buffer) >= self.config.buffer_ahead + 1:
if len(self._active_buffer) >= self.config.plugins_per_cycle:
break
# Ensure index is valid (guard against empty list)
@@ -521,28 +565,117 @@ class StreamManager:
logger.debug("Refreshed content for %s in staging buffer", plugin_id)
def _ensure_buffer_filled(self) -> None:
"""Ensure buffer has enough content prefetched."""
if len(self._active_buffer) < self.config.buffer_ahead:
needed = self.config.buffer_ahead - len(self._active_buffer)
self._prefetch_content(count=needed)
"""
Top the buffer back up after segments have been served.
buffer_ahead is the low-water mark only; plugins_per_cycle is the
ceiling and is enforced inside _prefetch_content.
"""
low_water = min(self.config.buffer_ahead, self.config.plugins_per_cycle)
if len(self._active_buffer) < low_water:
self._prefetch_content(count=low_water - len(self._active_buffer))
def get_all_content_for_composition(self) -> List[Image.Image]:
"""
Get all buffered content as a flat list of images.
Used when composing the full scroll image.
Skips STATIC segments as they don't have images to compose.
Prefer get_grouped_content_for_composition(): flattening loses the
plugin boundaries, which is what tells the compositor where a
separator belongs and where it does not.
Returns:
List of all images in buffer order
"""
all_images = []
for _plugin_id, images in self.get_grouped_content_for_composition():
all_images.extend(images)
return all_images
def get_grouped_content_for_composition(self) -> List[Tuple[str, List[Image.Image]]]:
"""
Get buffered content grouped by the plugin that produced it.
The grouping matters: separator_width is meant to mark the handoff from
one plugin to the next, not to sit between every row a single plugin
contributes. A per-row ticker like the F1 scoreboard returns over a
hundred images that it renders 4px apart internally, so flattening them
into one list and applying a uniform gap forced 32px between each of
its rows both inconsistent with how the plugin looks standalone, and
a large hidden addition to the width it occupies.
Skips STATIC segments, which trigger a pause rather than contributing
scroll content, and segments left with no images.
Returns:
List of (plugin_id, images) in buffer order
"""
grouped: List[Tuple[str, List[Image.Image]]] = []
with self._buffer_lock:
for segment in self._active_buffer:
# Skip STATIC segments - they trigger pauses, not scroll content
if segment.display_mode != VegasDisplayMode.STATIC:
all_images.extend(segment.images)
return all_images
if segment.display_mode == VegasDisplayMode.STATIC:
continue
if not segment.images:
continue
grouped.append((segment.plugin_id, list(segment.images)))
return grouped
def take_next_group(
self, count: Optional[int] = None, offscreen_only: bool = False
) -> List[Tuple[str, Optional[List[Image.Image]]]]:
"""
Fetch and hand over the next slice of the rotation.
For continuous scrolling, where the strip is extended rather than
replaced. Advances the rotation index so plugins come round in order
across an unbroken strip, and bypasses the active buffer entirely that
buffer exists to stage a *replacement* cycle, which continuous mode has
no use for.
Args:
count: Number of plugins to gather, defaulting to plugins_per_cycle
offscreen_only: Only use content paths that avoid the shared display
canvas, for use off the render thread
Returns:
Ordered list of (plugin_id, images). ``images`` is None when the
plugin could not be served under ``offscreen_only``, so the caller
can fetch just those on the render thread while keeping the order.
"""
if count is None:
count = self.config.plugins_per_cycle
self.refresh()
with self._buffer_lock:
if not self._ordered_plugins:
return []
total = len(self._ordered_plugins)
ids = []
for _ in range(min(max(1, count), total)):
ids.append(self._ordered_plugins[self._prefetch_index])
self._prefetch_index = (self._prefetch_index + 1) % total
plugins = getattr(self.plugin_manager, 'plugins', {})
group: List[Tuple[str, Optional[List[Image.Image]]]] = []
for plugin_id in ids:
plugin = plugins.get(plugin_id)
if not plugin:
continue
try:
images = self.plugin_adapter.get_content(
plugin, plugin_id, offscreen_only=offscreen_only)
except Exception:
logger.exception("[%s] ERROR fetching content", plugin_id)
self.stats['fetch_errors'] += 1
continue
if images:
self.stats['segments_fetched'] += 1
group.append((plugin_id, images if images else None))
return group
def advance_cycle(self) -> None:
"""
+336
View File
@@ -0,0 +1,336 @@
"""
Tests for ScrollHelper's continuous-strip primitives.
append_content extends the strip to the right without disturbing motion, and
drop_scrolled_prefix reclaims what has already gone past. Together they let a
caller keep one endless strip instead of swapping a new one in, which is what
shows as a flash and a hard cut to already-full-screen content.
"""
import numpy as np
import pytest
from PIL import Image
from src.common.scroll_helper import ScrollHelper
from src.vegas_mode.geometry import column_has_ink
W, H = 128, 32
def helper():
return ScrollHelper(W, H)
def block(width, colour=(255, 255, 255), height=H):
return Image.new('RGB', (width, height), colour)
class TestAppendContent:
def test_first_append_builds_the_strip(self):
sh = helper()
assert sh.append_content([block(100)], item_gap=0)
assert sh.cached_image is not None
assert sh.total_scroll_width == sh.cached_image.width
def test_strip_grows_by_content_plus_gaps(self):
sh = helper()
sh.create_scrolling_image([block(100)], item_gap=0, element_gap=0, lead_gap=0)
assert sh.cached_image.width == 100
sh.append_content([block(50)], item_gap=10, element_gap=0)
# one leading gap of 10 then the 50px block
assert sh.cached_image.width == 160
assert sh.total_scroll_width == 160
def test_scroll_position_is_preserved(self):
sh = helper()
sh.create_scrolling_image([block(400)], item_gap=0, element_gap=0, lead_gap=0)
sh.scroll_position = 137.0
sh.total_distance_scrolled = 137.0
sh.append_content([block(200)], item_gap=16)
assert sh.scroll_position == 137.0
assert sh.total_distance_scrolled == 137.0
def test_appending_defers_completion(self):
sh = helper()
sh.create_scrolling_image([block(200)], item_gap=0, element_gap=0, lead_gap=0)
sh.scroll_complete = True
sh.append_content([block(200)], item_gap=0)
assert not sh.scroll_complete
assert sh.total_distance_scrolled < sh.total_scroll_width
def test_existing_pixels_are_untouched(self):
sh = helper()
original = block(80, (10, 200, 10))
sh.create_scrolling_image([original], item_gap=0, element_gap=0, lead_gap=0)
before = sh.cached_image.crop((0, 0, 80, H)).tobytes()
sh.append_content([block(40, (200, 10, 10))], item_gap=8)
assert sh.cached_image.crop((0, 0, 80, H)).tobytes() == before
def test_appended_content_sits_after_the_gap(self):
sh = helper()
sh.create_scrolling_image([block(50)], item_gap=0, element_gap=0, lead_gap=0)
sh.append_content([block(30)], item_gap=12)
ink = column_has_ink(sh.cached_image)
assert ink[:50].all()
assert not ink[50:62].any() # the 12px gap
assert ink[62:92].all()
def test_array_and_image_stay_consistent(self):
# get_visible_portion slices cached_array but bounds-checks against
# cached_image.width, so a mismatch corrupts frames.
sh = helper()
sh.create_scrolling_image([block(200)], item_gap=0, element_gap=0, lead_gap=0)
sh.append_content([block(100)], item_gap=8)
assert sh.cached_array.shape[1] == sh.cached_image.width
assert sh.cached_array.shape[0] == sh.cached_image.height
def test_visible_portion_still_renders_after_append(self):
sh = helper()
sh.create_scrolling_image([block(300)], item_gap=0, element_gap=0, lead_gap=0)
sh.append_content([block(300)], item_gap=8)
sh.scroll_position = 250.0
frame = sh.get_visible_portion()
assert frame is not None and frame.size == (W, H)
def test_empty_append_is_a_no_op(self):
sh = helper()
sh.create_scrolling_image([block(100)], item_gap=0, element_gap=0, lead_gap=0)
assert sh.append_content([]) is False
assert sh.cached_image.width == 100
def test_repeated_appends_accumulate(self):
sh = helper()
sh.append_content([block(100)], item_gap=0)
for _ in range(5):
sh.append_content([block(100)], item_gap=0)
assert sh.cached_image.width == 600
class TestDropScrolledPrefix:
def test_removes_consumed_columns(self):
sh = helper()
sh.create_scrolling_image([block(1000)], item_gap=0, element_gap=0, lead_gap=0)
sh.scroll_position = 500.0
sh.total_distance_scrolled = 500.0
removed = sh.drop_scrolled_prefix(keep_before=0)
assert removed == 500
assert sh.cached_image.width == 500
assert sh.scroll_position == 0.0
def test_keeps_the_requested_margin(self):
sh = helper()
sh.create_scrolling_image([block(1000)], item_gap=0, element_gap=0, lead_gap=0)
sh.scroll_position = 500.0
sh.drop_scrolled_prefix(keep_before=100)
assert sh.scroll_position == 100.0
assert sh.cached_image.width == 600
def test_completion_difference_is_preserved(self):
# total_distance_scrolled and total_scroll_width must shift together, or
# trimming would spuriously complete or un-complete the cycle.
sh = helper()
sh.create_scrolling_image([block(1000)], item_gap=0, element_gap=0, lead_gap=0)
sh.scroll_position = 600.0
sh.total_distance_scrolled = 600.0
before = sh.total_scroll_width - sh.total_distance_scrolled
sh.drop_scrolled_prefix(keep_before=0)
assert sh.total_scroll_width - sh.total_distance_scrolled == before
def test_never_trims_below_the_viewport(self):
sh = helper()
sh.create_scrolling_image([block(200)], item_gap=0, element_gap=0, lead_gap=0)
sh.scroll_position = 190.0
sh.drop_scrolled_prefix(keep_before=0)
assert sh.cached_image.width >= W
def test_no_op_before_anything_has_scrolled(self):
sh = helper()
sh.create_scrolling_image([block(500)], item_gap=0, element_gap=0, lead_gap=0)
assert sh.drop_scrolled_prefix(keep_before=0) == 0
assert sh.cached_image.width == 500
def test_no_op_with_no_strip(self):
assert helper().drop_scrolled_prefix() == 0
def test_visible_frame_is_unchanged_by_trimming(self):
# The whole point: trimming is invisible. Same pixels on screen before
# and after. Position chosen so the viewport is well clear of the end,
# i.e. not wrapping.
sh = helper()
items = [block(200, (255, 0, 0)), block(200, (0, 255, 0)),
block(200, (0, 0, 255))]
sh.create_scrolling_image(items, item_gap=20, element_gap=0, lead_gap=0)
sh.scroll_position = 300.0
before = sh.get_visible_portion().tobytes()
assert sh.drop_scrolled_prefix(keep_before=0) > 0, "trim should have run"
after = sh.get_visible_portion().tobytes()
assert after == before
def test_refuses_to_trim_while_the_viewport_wraps(self):
# Wrapping reads the head of the strip into the right of the frame, so
# trimming the head there would visibly change the picture.
sh = helper()
sh.create_scrolling_image([block(200)], item_gap=0, element_gap=0, lead_gap=0)
sh.scroll_position = 150.0 # 150 + 128 > 200, so wrapping
before = sh.get_visible_portion().tobytes()
assert sh.drop_scrolled_prefix(keep_before=0) == 0
assert sh.get_visible_portion().tobytes() == before
def test_array_and_image_stay_consistent_after_trim(self):
sh = helper()
sh.create_scrolling_image([block(900)], item_gap=0, element_gap=0, lead_gap=0)
sh.scroll_position = 400.0
sh.drop_scrolled_prefix(keep_before=0)
assert sh.cached_array.shape[1] == sh.cached_image.width
class TestRemainingUnscrolled:
def test_counts_content_right_of_the_viewport(self):
sh = helper()
sh.create_scrolling_image([block(500)], item_gap=0, element_gap=0, lead_gap=0)
assert sh.remaining_unscrolled() == 500 - W
def test_shrinks_as_the_strip_scrolls(self):
sh = helper()
sh.create_scrolling_image([block(500)], item_gap=0, element_gap=0, lead_gap=0)
sh.scroll_position = 200.0
assert sh.remaining_unscrolled() == 500 - 200 - W
def test_never_negative(self):
sh = helper()
sh.create_scrolling_image([block(200)], item_gap=0, element_gap=0, lead_gap=0)
sh.scroll_position = 500.0
assert sh.remaining_unscrolled() == 0
def test_zero_with_no_strip(self):
assert helper().remaining_unscrolled() == 0
def test_grows_when_content_is_appended(self):
sh = helper()
sh.create_scrolling_image([block(600)], item_gap=0, element_gap=0, lead_gap=0)
sh.scroll_position = 100.0
before = sh.remaining_unscrolled()
assert before > 0, "fixture should leave content ahead of the viewport"
sh.append_content([block(400)], item_gap=0)
assert sh.remaining_unscrolled() == before + 400
class TestContinuousScrollingEndToEnd:
def test_strip_can_be_extended_indefinitely_at_bounded_size(self):
"""The invariant that makes this viable: extend + trim keeps the strip
bounded while motion never stops."""
sh = helper()
sh.create_scrolling_image([block(600)], item_gap=0, element_gap=0, lead_gap=0)
widths = []
for _ in range(20):
sh.scroll_position += 200
sh.total_distance_scrolled += 200
if sh.remaining_unscrolled() < 2 * W:
sh.append_content([block(600)], item_gap=16)
sh.drop_scrolled_prefix(keep_before=W)
widths.append(sh.cached_image.width)
# A frame must always be renderable.
assert sh.get_visible_portion() is not None
assert max(widths) < 3000, f"strip grew unbounded: max {max(widths)}"
assert not sh.scroll_complete, "continuous strip should never complete"
class TestSubPixelBlending:
"""
Integer positioning quantises motion to whole pixels, so distinct frames per
second equals scroll speed regardless of frame rate at 50px/s and 78fps,
36% of frames were identical. Blending between neighbouring positions gives
motion at the frame rate instead.
"""
def _strip(self, width=2000):
rng = np.random.default_rng(0)
arr = (rng.random((H, width, 3)) * 255).astype(np.uint8)
sh = helper()
sh.create_scrolling_image([Image.fromarray(arr)],
item_gap=0, element_gap=0, lead_gap=0)
return sh
def _frame(self, sh, pos, subpixel):
sh.sub_pixel_scrolling = subpixel
sh.scroll_position = pos
return np.asarray(sh.get_visible_portion()).astype(int)
def test_integer_mode_ignores_the_fraction(self):
sh = self._strip()
a = self._frame(sh, 500.0, False)
b = self._frame(sh, 500.9, False)
assert np.array_equal(a, b), "integer positioning should not move sub-pixel"
def test_blending_moves_within_a_pixel(self):
sh = self._strip()
a = self._frame(sh, 500.0, True)
b = self._frame(sh, 500.5, True)
assert not np.array_equal(a, b)
def test_zero_fraction_matches_the_integer_frame(self):
# No interpolation to do, so it must be pixel-identical and take the
# cheap path.
sh = self._strip()
assert np.array_equal(self._frame(sh, 700.0, True),
self._frame(sh, 700.0, False))
def test_blend_is_monotonic_between_neighbours(self):
# Marching the fraction from 0 to 1 should approach the next integer
# frame, not wander.
sh = self._strip()
target = self._frame(sh, 501.0, False)
dists = []
for frac in (0.0, 0.25, 0.5, 0.75):
f = self._frame(sh, 500.0 + frac, True)
dists.append(np.abs(f - target).mean())
assert dists == sorted(dists, reverse=True), f"not converging: {dists}"
def test_blend_endpoints_bracket_the_two_frames(self):
sh = self._strip()
near = self._frame(sh, 500.0, False)
far = self._frame(sh, 501.0, False)
mid = self._frame(sh, 500.5, True)
# Every blended pixel must lie between its two sources.
lo = np.minimum(near, far)
hi = np.maximum(near, far)
assert (mid >= lo - 1).all() and (mid <= hi + 1).all()
def test_output_size_and_mode_are_unchanged(self):
sh = self._strip()
sh.sub_pixel_scrolling = True
sh.scroll_position = 300.4
frame = sh.get_visible_portion()
assert frame.size == (W, H)
assert frame.mode == 'RGB'
def test_works_near_the_end_of_the_strip(self):
# One of the two slices wraps here; must not raise or missize.
sh = self._strip(width=600)
sh.sub_pixel_scrolling = True
sh.scroll_position = float(600 - W // 2) + 0.5
frame = sh.get_visible_portion()
assert frame is not None and frame.size == (W, H)
def test_works_at_the_very_last_column(self):
sh = self._strip(width=600)
sh.sub_pixel_scrolling = True
sh.scroll_position = 599.5
assert sh.get_visible_portion().size == (W, H)
@pytest.mark.parametrize("frac", [0.01, 0.1, 0.33, 0.5, 0.67, 0.9, 0.99])
def test_never_raises_across_the_fraction_range(self, frac):
sh = self._strip()
sh.sub_pixel_scrolling = True
sh.scroll_position = 400.0 + frac
assert sh.get_visible_portion().size == (W, H)
+227
View File
@@ -0,0 +1,227 @@
"""
Regression tests: changed plugin data must reach the strip in continuous mode.
Two faults combined to freeze Vegas content indefinitely.
PR #291 added a call to ``plugin_adapter.invalidate_plugin_scroll_cache()`` so a
plugin's *own* cached scroll image would be rebuilt from fresh data. The method
was never implemented, and ``hot_swap_content()`` wraps the call in a broad
except, so every hot swap raised AttributeError and was silently swallowed.
Continuous scrolling then removed the only path that reached it at all:
``should_recompose()``/``hot_swap_content()`` are called from the non-continuous
branch, while ``continuous_scroll`` defaults to True.
Together, a plugin composed its scroll image once and handed back the same
picture forever, because the sports plugins' ``get_vegas_content()`` regenerates
only when its cache is empty. Symptom: a game that was live last night is still
drawn as live the following morning.
"""
from types import SimpleNamespace
from unittest.mock import MagicMock
import numpy as np
from PIL import Image
from src.vegas_mode.config import VegasModeConfig
from src.vegas_mode.plugin_adapter import PluginAdapter
from src.vegas_mode.render_pipeline import RenderPipeline
from src.vegas_mode.stream_manager import StreamManager
class FakeDisplayManager:
width = 64
height = 32
def _helper():
"""A stand-in ScrollHelper holding both halves of its cache."""
image = Image.new('RGB', (128, 32), (10, 20, 30))
return SimpleNamespace(cached_image=image, cached_array=np.array(image))
class TestInvalidatePluginScrollCache:
"""The method PR #291 called but never defined."""
def test_method_exists(self):
# It was called for months without existing; the broad except in
# hot_swap_content() meant nothing ever surfaced.
assert hasattr(PluginAdapter, 'invalidate_plugin_scroll_cache')
def test_clears_helper_attached_to_the_plugin(self):
adapter = PluginAdapter(FakeDisplayManager(), VegasModeConfig())
helper = _helper()
plugin = SimpleNamespace(scroll_helper=helper)
assert adapter.invalidate_plugin_scroll_cache(plugin, 'stocks') is True
assert helper.cached_image is None
assert helper.cached_array is None
def test_clears_helper_owned_by_a_scroll_manager(self):
# The sports scoreboards keep theirs on _scroll_manager, which is the
# layout that produced the reported stale-scores bug.
adapter = PluginAdapter(FakeDisplayManager(), VegasModeConfig())
helper = _helper()
plugin = SimpleNamespace(_scroll_manager=SimpleNamespace(scroll_helper=helper))
assert adapter.invalidate_plugin_scroll_cache(plugin, 'baseball') is True
assert helper.cached_image is None
assert helper.cached_array is None
def test_clears_both_halves_together(self):
# cached_array is the image's numpy mirror; leaving one behind lets a
# reader pick up content the other no longer has.
adapter = PluginAdapter(FakeDisplayManager(), VegasModeConfig())
helper = _helper()
adapter.invalidate_plugin_scroll_cache(
SimpleNamespace(scroll_helper=helper), 'news')
assert (helper.cached_image, helper.cached_array) == (None, None)
def test_plugin_without_a_helper_is_not_an_error(self):
adapter = PluginAdapter(FakeDisplayManager(), VegasModeConfig())
assert adapter.invalidate_plugin_scroll_cache(SimpleNamespace(), 'clock') is False
class TestInvalidatePendingUpdates:
def _manager(self, plugins):
stream = StreamManager(
VegasModeConfig(),
SimpleNamespace(plugins=plugins),
MagicMock(),
)
stream.plugin_adapter = MagicMock()
return stream
def test_drops_caches_for_updated_plugins(self):
helper = _helper()
plugin = SimpleNamespace(scroll_helper=helper)
stream = self._manager({'baseball': plugin})
stream.mark_plugin_updated('baseball')
assert stream.invalidate_pending_updates() == ['baseball']
stream.plugin_adapter.invalidate_cache.assert_called_once_with('baseball')
stream.plugin_adapter.invalidate_plugin_scroll_cache.assert_called_once_with(
plugin, 'baseball')
def test_pending_flags_are_consumed(self):
# Left unconsumed they accumulate forever and nothing ever refreshes.
stream = self._manager({'baseball': SimpleNamespace()})
stream.mark_plugin_updated('baseball')
assert stream.has_pending_updates() is True
stream.invalidate_pending_updates()
assert stream.has_pending_updates() is False
assert stream.invalidate_pending_updates() == []
def test_no_pending_updates_does_no_work(self):
stream = self._manager({})
assert stream.invalidate_pending_updates() == []
stream.plugin_adapter.invalidate_cache.assert_not_called()
def test_a_failing_plugin_does_not_stop_the_others(self):
stream = self._manager({'a': SimpleNamespace(), 'b': SimpleNamespace()})
stream.mark_plugin_updated('a')
stream.mark_plugin_updated('b')
stream.plugin_adapter.invalidate_cache.side_effect = [
RuntimeError('boom'), None]
assert sorted(stream.invalidate_pending_updates()) == ['a', 'b']
assert stream.plugin_adapter.invalidate_cache.call_count == 2
class TestContinuousModeReachesTheRefresh:
def _pipeline(self):
stream = MagicMock()
stream.get_buffer_status.return_value = {'staging_count': 0}
return RenderPipeline(VegasModeConfig(), FakeDisplayManager(), stream), stream
def test_refresh_delegates_to_the_stream_manager(self):
pipeline, stream = self._pipeline()
stream.invalidate_pending_updates.return_value = ['baseball']
assert pipeline.refresh_updated_plugins() is True
def test_refresh_reports_false_when_nothing_changed(self):
pipeline, stream = self._pipeline()
stream.invalidate_pending_updates.return_value = []
assert pipeline.refresh_updated_plugins() is False
def test_refresh_never_raises_into_the_render_loop(self):
pipeline, stream = self._pipeline()
stream.invalidate_pending_updates.side_effect = RuntimeError('boom')
assert pipeline.refresh_updated_plugins() is False
def test_refresh_does_not_reposition_the_scroll(self):
# The whole point of preferring this over hot_swap_content(): that path
# rebuilds and repositions, which reads as a freeze then a jump.
pipeline, stream = self._pipeline()
stream.invalidate_pending_updates.return_value = ['baseball']
pipeline.scroll_helper.scroll_position = 1234
pipeline.refresh_updated_plugins()
assert pipeline.scroll_helper.scroll_position == 1234
stream.swap_buffers.assert_not_called()
stream.process_updates.assert_not_called()
class TestCoordinatorWiring:
"""
The regression itself: continuous mode has to *call* the refresh.
should_recompose()/hot_swap_content() sit in the non-continuous branch, and
continuous_scroll defaults to True, so before this fix the refresh was
simply never reached on a default install.
"""
def _coordinator(self, continuous):
import threading
from src.vegas_mode.coordinator import VegasModeCoordinator
config = VegasModeConfig()
config.continuous_scroll = continuous
# Built without __init__ so the test exercises run_frame's branching
# without standing up a display, stream and render stack.
coordinator = VegasModeCoordinator.__new__(VegasModeCoordinator)
coordinator.vegas_config = config
coordinator.render_pipeline = MagicMock()
coordinator.render_pipeline.has_deferred.return_value = False
coordinator.render_pipeline.needs_extension.return_value = False
coordinator.render_pipeline.is_cycle_complete.return_value = False
coordinator.render_pipeline.should_recompose.return_value = False
coordinator.stream_manager = MagicMock()
coordinator.stats = {'cycles_completed': 0}
coordinator._state_lock = threading.Lock()
coordinator._is_active = True
coordinator._is_paused = False
coordinator._should_stop = False
coordinator._pending_config_update = False
coordinator._live_priority_check = None
coordinator._interrupt_check = None
coordinator.sync_manager = None
return coordinator
def test_continuous_mode_refreshes_updated_plugins_every_frame(self):
coordinator = self._coordinator(continuous=True)
coordinator.run_frame()
coordinator.render_pipeline.refresh_updated_plugins.assert_called_once()
def test_continuous_mode_does_not_use_the_disruptive_swap(self):
coordinator = self._coordinator(continuous=True)
coordinator.run_frame()
coordinator.render_pipeline.hot_swap_content.assert_not_called()
def test_swap_mode_still_uses_hot_swap(self):
# The non-continuous path must keep its original behaviour.
coordinator = self._coordinator(continuous=False)
coordinator.render_pipeline.should_recompose.return_value = True
coordinator.run_frame()
coordinator.render_pipeline.hot_swap_content.assert_called_once()
coordinator.render_pipeline.refresh_updated_plugins.assert_not_called()
def test_a_frame_is_still_rendered_either_way(self):
for continuous in (True, False):
coordinator = self._coordinator(continuous=continuous)
coordinator.run_frame()
coordinator.render_pipeline.render_frame.assert_called_once()
File diff suppressed because it is too large Load Diff
+370
View File
@@ -0,0 +1,370 @@
"""Tests for Vegas mode geometry primitives."""
import numpy as np
import pytest
from PIL import Image
from src.vegas_mode.geometry import (
DEFAULT_INK_THRESHOLD,
column_has_ink,
content_bounds,
dead_window_stats,
edge_blank,
find_blank_cut,
separation_gap,
trim_to_content,
window_coverage_stats,
)
def make_img(width, height=8, fill=(0, 0, 0)):
return Image.new('RGB', (width, height), fill)
def paint(img, x0, x1, color=(255, 255, 255)):
"""Fill columns [x0, x1) with a colour."""
block = Image.new('RGB', (x1 - x0, img.height), color)
img.paste(block, (x0, 0))
return img
class TestColumnHasInk:
def test_all_black_has_no_ink(self):
assert not column_has_ink(make_img(16)).any()
def test_marks_only_painted_columns(self):
img = paint(make_img(16), 4, 8)
ink = column_has_ink(img)
assert ink.tolist() == [False] * 4 + [True] * 4 + [False] * 8
def test_threshold_is_exclusive(self):
# A pixel exactly at the threshold is not ink; one above it is.
at = paint(make_img(4), 0, 4, (DEFAULT_INK_THRESHOLD,) * 3)
above = paint(make_img(4), 0, 4, (DEFAULT_INK_THRESHOLD + 1,) * 3)
assert not column_has_ink(at).any()
assert column_has_ink(above).all()
def test_single_bright_channel_counts(self):
img = paint(make_img(4), 1, 2, (0, 0, 200))
assert column_has_ink(img).tolist() == [False, True, False, False]
def test_one_lit_pixel_lights_the_column(self):
img = make_img(4, height=8)
img.putpixel((2, 5), (255, 255, 255))
assert column_has_ink(img).tolist() == [False, False, True, False]
class TestContentBounds:
def test_blank_returns_none(self):
assert content_bounds(make_img(16)) is None
def test_finds_inclusive_bounds(self):
assert content_bounds(paint(make_img(20), 5, 12)) == (5, 11)
def test_full_width_content(self):
assert content_bounds(paint(make_img(10), 0, 10)) == (0, 9)
def test_spans_interior_gap(self):
img = paint(make_img(30), 2, 5)
paint(img, 20, 25)
assert content_bounds(img) == (2, 24)
class TestTrimToContent:
def test_blank_image_reports_blank(self):
result = trim_to_content(make_img(512))
assert result.is_blank
assert result.image is None
assert result.width == 0
assert result.original_width == 512
def test_trims_both_edges(self):
result = trim_to_content(paint(make_img(512), 100, 150))
assert not result.is_blank
assert result.width == 50
assert result.trimmed_left == 100
assert result.trimmed_right == 362
assert result.removed == 462
def test_preserves_interior_gap(self):
# Two content blocks with a wide blank between them: the gap is the
# plugin's layout and must survive trimming.
img = paint(make_img(400), 50, 80)
paint(img, 300, 330)
result = trim_to_content(img)
assert result.width == 280 # 50..329 inclusive
assert column_has_ink(result.image).sum() == 60
def test_full_width_content_is_returned_unchanged(self):
img = paint(make_img(128), 0, 128)
result = trim_to_content(img)
assert result.image is img
assert result.removed == 0
def test_non_black_background_is_never_trimmed(self):
# A plugin drawing on a dark-but-not-black background fills every
# column with ink, so there is nothing to reclaim.
result = trim_to_content(make_img(256, fill=(0, 0, 40)))
assert result.removed == 0
assert result.width == 256
def test_padding_keeps_margin_up_to_what_exists(self):
result = trim_to_content(paint(make_img(512), 100, 150), padding=8)
assert result.trimmed_left == 92
assert result.width == 66 # 50 content + 8 each side
def test_padding_cannot_widen_beyond_original(self):
# Content starts 2px in; padding of 8 can only reclaim the 2 available.
result = trim_to_content(paint(make_img(64), 2, 60), padding=8)
assert result.trimmed_left == 0
assert result.trimmed_right == 0
assert result.width == 64
def test_height_is_preserved(self):
result = trim_to_content(paint(make_img(200, height=64), 10, 20))
assert result.image.height == 64
def test_real_world_of_the_day_case(self):
# Measured on devpi: "No Data" occupying 35px of a 512px canvas.
result = trim_to_content(paint(make_img(512, height=64), 4, 39))
assert result.width == 35
assert result.removed == 477
class TestDeadWindowStats:
def test_fully_inked_ticker_has_no_dead_windows(self):
stats = dead_window_stats(paint(make_img(400), 0, 400), viewport_width=100)
assert stats.dead_windows == 0
assert stats.dead_ratio == 0.0
assert stats.longest_dead_run == 0
def test_fully_blank_ticker_is_all_dead(self):
stats = dead_window_stats(make_img(400), viewport_width=100)
assert stats.total_windows == 301
assert stats.dead_windows == 301
assert stats.dead_ratio == 1.0
assert stats.longest_dead_run == 301
def test_leading_blank_run_is_measured(self):
# 512px of black then solid content: windows fully inside the black
# stretch are dead. With a 100px viewport, starts 0..412 exist and a
# window is dead while it holds >=95 blank columns.
img = paint(make_img(1024), 512, 1024)
stats = dead_window_stats(img, viewport_width=100)
assert stats.dead_windows == 418 # starts 0..417 keep >=95 blank cols
assert stats.longest_dead_run == 418
def test_narrow_content_island_still_leaves_dead_windows(self):
# 35px of content in a 512px field, viewed 100px at a time: no window
# can be 95% blank once it overlaps 35 lit columns, but the windows
# clear of it are dead.
img = paint(make_img(512), 100, 135)
stats = dead_window_stats(img, viewport_width=100)
assert stats.dead_windows > 0
assert stats.dead_ratio == pytest.approx(
stats.dead_windows / stats.total_windows
)
def test_step_reduces_sampling(self):
img = paint(make_img(1000), 500, 1000)
exact = dead_window_stats(img, viewport_width=100, step=1)
strided = dead_window_stats(img, viewport_width=100, step=10)
assert strided.total_windows < exact.total_windows
# Same underlying shape, so the ratios should stay close.
assert strided.dead_ratio == pytest.approx(exact.dead_ratio, abs=0.02)
def test_image_narrower_than_viewport_is_one_window(self):
stats = dead_window_stats(make_img(50), viewport_width=100)
assert stats.total_windows == 1
assert stats.dead_windows == 1
def test_zero_viewport_is_handled(self):
stats = dead_window_stats(make_img(50), viewport_width=0)
assert stats.total_windows == 0
assert stats.dead_ratio == 0.0
def test_longest_run_picks_the_larger_of_two_gaps(self):
# Short blank gap, content, then a long blank gap.
img = make_img(1000)
paint(img, 150, 400)
paint(img, 500, 520)
stats = dead_window_stats(img, viewport_width=100)
# The 400..500 gap is only 100 wide; the tail from 520 is 480 wide.
assert stats.longest_dead_run >= 380
class TestWindowCoverageStats:
def test_solid_content_is_fully_covered(self):
stats = window_coverage_stats(paint(make_img(600), 0, 600), viewport_width=100)
assert stats.mean_ink_ratio == 1.0
assert stats.min_ink_ratio == 1.0
assert stats.sparse_windows == 0
def test_blank_strip_is_entirely_sparse(self):
stats = window_coverage_stats(make_img(600), viewport_width=100)
assert stats.mean_ink_ratio == 0.0
assert stats.sparse_ratio == 1.0
def test_catches_sliver_windows_that_dead_ratio_misses(self):
# Narrow content islands separated by more than the viewport. A window
# holding one whole 40px island carries 472 blank columns — under the
# 486 needed to count as "dead" — yet only 7.8% ink, so it still reads
# as an empty panel. Coverage must flag strictly more positions than
# the dead-window scan does.
img = paint(make_img(2000), 0, 40)
paint(img, 1000, 1040)
dead = dead_window_stats(img, viewport_width=512)
cover = window_coverage_stats(img, viewport_width=512, sparse_ink_ratio=0.10)
assert cover.sparse_windows > dead.dead_windows
assert cover.min_ink_ratio == 0.0
def test_adjacent_full_width_segments_stay_partially_covered(self):
# Documents why the dead-window scan alone understated the problem:
# two 512px segments with mid-canvas content never fully blank the
# viewport, they just hold it at a thin ~28%.
img = paint(make_img(1024), 185, 330)
paint(img, 697, 842)
dead = dead_window_stats(img, viewport_width=512)
cover = window_coverage_stats(img, viewport_width=512)
assert dead.dead_windows == 0
assert cover.mean_ink_ratio == pytest.approx(0.283, abs=0.01)
def test_min_ink_ratio_finds_the_worst_position(self):
# A wide blank tail guarantees at least one totally empty viewport.
img = paint(make_img(1200), 0, 200)
stats = window_coverage_stats(img, viewport_width=200)
assert stats.min_ink_ratio == 0.0
assert stats.mean_ink_ratio > 0.0
def test_sparse_threshold_is_respected(self):
# 40 inked columns in a 200px viewport = 20% coverage everywhere the
# island is fully inside the window.
img = paint(make_img(400), 100, 140)
lenient = window_coverage_stats(img, viewport_width=200, sparse_ink_ratio=0.05)
strict = window_coverage_stats(img, viewport_width=200, sparse_ink_ratio=0.50)
assert strict.sparse_windows > lenient.sparse_windows
def test_step_approximates_exact_scan(self):
img = paint(make_img(2000), 300, 500)
paint(img, 1200, 1400)
exact = window_coverage_stats(img, viewport_width=512, step=1)
strided = window_coverage_stats(img, viewport_width=512, step=4)
assert strided.mean_ink_ratio == pytest.approx(exact.mean_ink_ratio, abs=0.01)
def test_zero_viewport_is_handled(self):
stats = window_coverage_stats(make_img(50), viewport_width=0)
assert stats.total_windows == 0
assert stats.sparse_ratio == 0.0
def test_image_narrower_than_viewport(self):
stats = window_coverage_stats(paint(make_img(50), 0, 50), viewport_width=100)
assert stats.total_windows == 1
assert stats.mean_ink_ratio == pytest.approx(0.5)
class TestLongestRunHelper:
@pytest.mark.parametrize("flags,expected", [
([], 0),
([False, False], 0),
([True], 1),
([True, True, False, True], 2),
([False, True, True, True, False, True], 3),
([True, True, True], 3),
])
def test_run_lengths(self, flags, expected):
from src.vegas_mode.geometry import _longest_true_run
assert _longest_true_run(np.array(flags, dtype=bool)) == expected
class TestEdgeBlank:
def test_measures_both_edges(self):
assert edge_blank(paint(make_img(100), 20, 60)) == (20, 40)
def test_flush_content_has_no_blank(self):
assert edge_blank(paint(make_img(50), 0, 50)) == (0, 0)
def test_blank_image_reports_full_width_both_sides(self):
# No ink means nothing to be close to.
assert edge_blank(make_img(64)) == (64, 64)
class TestSeparationGap:
def test_flush_edges_get_the_full_target(self):
a = paint(make_img(50), 0, 50)
b = paint(make_img(50), 0, 50)
assert separation_gap(a, b, target=24) == 24
def test_existing_margins_reduce_the_added_gap(self):
# 8px blank on each facing edge already covers 16 of the 24 target.
a = paint(make_img(50), 0, 42)
b = paint(make_img(50), 8, 50)
assert separation_gap(a, b, target=24) == 8
def test_ample_existing_margin_adds_nothing(self):
a = paint(make_img(100), 0, 60)
b = paint(make_img(100), 40, 100)
assert separation_gap(a, b, target=24) == 0
def test_minimum_is_a_floor(self):
a = paint(make_img(100), 0, 60)
b = paint(make_img(100), 40, 100)
assert separation_gap(a, b, target=24, minimum=4) == 4
def test_never_negative(self):
a = paint(make_img(200), 0, 10)
b = paint(make_img(200), 190, 200)
assert separation_gap(a, b, target=8) == 0
def test_sports_card_case_gets_real_separation(self):
# The reported problem: cards drawn edge to edge sat 8px apart under a
# flat gap; measured separation lifts them to the 24px target.
card = paint(make_img(150), 0, 150)
assert separation_gap(card, card, target=24, minimum=8) == 24
class TestFindBlankCut:
def test_snaps_to_the_nearest_gap(self):
img = paint(make_img(200), 0, 90)
paint(img, 110, 200)
# 100 is inside the 90..110 gap already.
assert find_blank_cut(img, 100, 20) == 100
def test_walks_outwards_to_find_a_gap(self):
img = paint(make_img(200), 0, 95)
paint(img, 105, 200)
cut = find_blank_cut(img, 90, 20)
assert 95 <= cut < 105
def test_solid_ink_returns_the_target(self):
assert find_blank_cut(paint(make_img(200), 0, 200), 100, 20) == 100
def test_target_at_image_width_does_not_index_past_the_end(self):
# A cut after the last column is legal. Indexing ink[width] raised
# IndexError in the field, losing that plugin's content for the cycle.
# Reached once the rotation offset advances so start + budget lands
# exactly on the image width.
img = paint(make_img(1840), 0, 1840)
assert find_blank_cut(img, 1840, 32) == 1840
def test_target_past_image_width_is_clamped(self):
img = paint(make_img(100), 0, 100)
assert find_blank_cut(img, 500, 32) == 100
def test_target_at_width_with_a_trailing_gap_snaps_back(self):
# Content 0..179, blank 180..199. The nearest blank column to 200 is
# 199, not the start of the gap — nearest is what keeps the cut as
# close as possible to the requested budget.
img = paint(make_img(200), 0, 180)
assert find_blank_cut(img, 200, 32) == 199
def test_zero_radius_returns_the_target(self):
assert find_blank_cut(paint(make_img(100), 0, 100), 50, 0) == 50
def test_negative_target_is_clamped_to_zero(self):
assert find_blank_cut(paint(make_img(100), 0, 100), -20, 8) == 0
@pytest.mark.parametrize("target", [0, 1, 50, 99, 100])
def test_never_raises_across_the_range(self, target):
img = paint(make_img(100), 0, 100)
cut = find_blank_cut(img, target, 16)
assert 0 <= cut <= 100
+86 -6
View File
@@ -918,7 +918,15 @@ def save_main_config():
# Handle Vegas scroll mode settings
vegas_fields = ['vegas_scroll_enabled', 'vegas_scroll_speed', 'vegas_separator_width',
'vegas_target_fps', 'vegas_buffer_ahead', 'vegas_plugin_order', 'vegas_excluded_plugins']
'vegas_target_fps', 'vegas_buffer_ahead', 'vegas_plugin_order', 'vegas_excluded_plugins',
'vegas_auto_trim', 'vegas_trim_threshold', 'vegas_content_padding',
'vegas_min_plugin_width', 'vegas_lead_in_width', 'vegas_plugins_per_cycle',
'vegas_max_plugin_width_ratio', 'vegas_dynamic_duration_enabled',
'vegas_min_cycle_duration', 'vegas_max_cycle_duration',
'vegas_intra_plugin_gap', 'vegas_render_width_pct',
'vegas_min_content_separation', 'vegas_min_cut_gap',
'vegas_continuous_scroll', 'vegas_extend_threshold_screens',
'vegas_smooth_scroll', 'vegas_overflow_mode']
if any(k in data for k in vegas_fields):
if 'display' not in current_config:
@@ -933,13 +941,85 @@ def save_main_config():
# was submitted (any vegas field present) but enabled key is missing,
# the checkbox was unchecked and we should set enabled=False
vegas_config['enabled'] = _coerce_to_bool(data.get('vegas_scroll_enabled'))
vegas_config['auto_trim'] = _coerce_to_bool(data.get('vegas_auto_trim'))
vegas_config['dynamic_duration_enabled'] = _coerce_to_bool(
data.get('vegas_dynamic_duration_enabled'))
vegas_config['continuous_scroll'] = _coerce_to_bool(
data.get('vegas_continuous_scroll'))
vegas_config['smooth_scroll'] = _coerce_to_bool(
data.get('vegas_smooth_scroll'))
# Handle numeric settings with validation
# max_plugin_width_ratio is the one fractional setting, so it is
# handled outside the integer loop below.
if data.get('vegas_overflow_mode') not in ('', None):
mode = str(data['vegas_overflow_mode']).strip().lower()
if mode not in ('rotate', 'truncate'):
return jsonify({
'status': 'error',
'message': "Invalid value for vegas_overflow_mode: "
"must be 'rotate' or 'truncate'"
}), 400
vegas_config['overflow_mode'] = mode
if data.get('vegas_extend_threshold_screens') not in ('', None):
try:
screens = float(data['vegas_extend_threshold_screens'])
except (ValueError, TypeError):
return jsonify({
'status': 'error',
'message': "Invalid value for vegas_extend_threshold_screens: "
"must be a number"
}), 400
if not (1.0 <= screens <= 10.0):
return jsonify({
'status': 'error',
'message': "Invalid value for vegas_extend_threshold_screens: "
"must be between 1.0 and 10.0"
}), 400
vegas_config['extend_threshold_screens'] = screens
if data.get('vegas_max_plugin_width_ratio') not in ('', None):
try:
ratio = float(data['vegas_max_plugin_width_ratio'])
except (ValueError, TypeError):
return jsonify({
'status': 'error',
'message': "Invalid value for vegas_max_plugin_width_ratio: "
"must be a number"
}), 400
if not (0 <= ratio <= 20):
return jsonify({
'status': 'error',
'message': "Invalid value for vegas_max_plugin_width_ratio: "
"must be between 0 and 20 (0 disables the cap)"
}), 400
vegas_config['max_plugin_width_ratio'] = ratio
# Handle numeric settings with validation.
#
# These bounds must match VegasModeConfig.validate(), which is what
# actually gates Vegas starting. Where they were looser, a value
# saved with a 200 and then made VegasModeCoordinator.start() bail
# out with only a log line, so the ticker silently never ran.
# Where they were tighter (scroll_speed capped at 100 against a
# slider that goes to 200), a legitimate value was rejected with a
# 400. See test_vegas_api_bounds_match_validate.
numeric_fields = {
'vegas_scroll_speed': ('scroll_speed', 1, 100),
'vegas_separator_width': ('separator_width', 0, 500),
'vegas_target_fps': ('target_fps', 1, 200),
'vegas_buffer_ahead': ('buffer_ahead', 1, 20),
'vegas_scroll_speed': ('scroll_speed', 1, 200),
'vegas_separator_width': ('separator_width', 0, 128),
'vegas_intra_plugin_gap': ('intra_plugin_gap', 0, 128),
'vegas_render_width_pct': ('render_width_pct', 10, 100),
'vegas_min_content_separation': ('min_content_separation', 0, 256),
'vegas_min_cut_gap': ('min_cut_gap', 1, 128),
'vegas_target_fps': ('target_fps', 30, 200),
'vegas_buffer_ahead': ('buffer_ahead', 1, 5),
'vegas_trim_threshold': ('trim_threshold', 0, 254),
'vegas_content_padding': ('content_padding', 0, 128),
'vegas_min_plugin_width': ('min_plugin_width', 0, 512),
'vegas_lead_in_width': ('lead_in_width', 0, 2048),
'vegas_plugins_per_cycle': ('plugins_per_cycle', 1, 50),
'vegas_min_cycle_duration': ('min_cycle_duration', 5, 3600),
'vegas_max_cycle_duration': ('max_cycle_duration', 10, 3600),
}
for field_name, (config_key, min_val, max_val) in numeric_fields.items():
if field_name in data:
@@ -425,7 +425,7 @@
</div>
<div class="form-group" id="setting-display-vegas_separator_width" data-setting-key="display.vegas_scroll.separator_width">
<label for="vegas_separator_width" class="block text-sm font-medium text-gray-700">Separator Width (pixels){{ ui.help_tip('Blank gap inserted between each plugin block in the ticker (0128 px).\nDefault: 32. Larger values make the boundary between plugins clearer.', 'Separator Width') }}</label>
<label for="vegas_separator_width" class="block text-sm font-medium text-gray-700">Separator Width (pixels){{ ui.help_tip('Blank gap where one plugin hands off to the next (0128 px).\nDefault: 32. Larger values make the boundary between plugins clearer. This does not apply between rows of the same plugin — see Row Gap for that.', 'Separator Width') }}</label>
<input type="number"
id="vegas_separator_width"
name="vegas_separator_width"
@@ -436,6 +436,44 @@
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-group" id="setting-display-vegas_intra_plugin_gap" data-setting-key="display.vegas_scroll.intra_plugin_gap">
<label for="vegas_intra_plugin_gap" class="block text-sm font-medium text-gray-700">Row Gap (pixels){{ ui.help_tip('Extra gap always added between rows contributed by the same plugin (0128 px).\nDefault: 8. This is a floor on top of Row Separation below, which does most of the work. Set both to 0 to butt rows directly together.', 'Row Gap') }}</label>
<input type="number"
id="vegas_intra_plugin_gap"
name="vegas_intra_plugin_gap"
value="{{ main_config.display.get('vegas_scroll', {}).get('intra_plugin_gap', 8) }}"
min="0"
max="128"
class="form-control">
</div>
<div class="form-group" id="setting-display-vegas_min_content_separation" data-setting-key="display.vegas_scroll.min_content_separation">
<label for="vegas_min_content_separation" class="block text-sm font-medium text-gray-700">Row Separation (pixels){{ ui.help_tip('Blank space guaranteed between rows of the same plugin, measured from the actual content rather than added blindly (0256 px).\nDefault: 24. Rows already carrying wide margins get nothing added; rows drawn right up to their own edges — sports score cards, for instance — get the full amount, so they no longer look like they are touching. Raise it if items still feel cramped.', 'Row Separation') }}</label>
<input type="number"
id="vegas_min_content_separation"
name="vegas_min_content_separation"
value="{{ main_config.display.get('vegas_scroll', {}).get('min_content_separation', 24) }}"
min="0"
max="256"
class="form-control">
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-group" id="setting-display-vegas_render_width_pct" data-setting-key="display.vegas_scroll.render_width_pct">
<label for="vegas_render_width_pct" class="block text-sm font-medium text-gray-700">Plugin Render Width (%){{ ui.help_tip('How much of the screen width each plugin is told it has while drawing for the ticker (10100%).\nDefault: 100 (unchanged). Lowering it makes plugins choose a tighter layout rather than being cropped — a weather forecast becomes narrow cards instead of five columns spread across the panel. Useful on wide displays. Override per plugin with the vegas_width_pct setting in that plugin\'s own configuration.', 'Plugin Render Width') }}</label>
<input type="number"
id="vegas_render_width_pct"
name="vegas_render_width_pct"
value="{{ main_config.display.get('vegas_scroll', {}).get('render_width_pct', 100) }}"
min="10"
max="100"
step="5"
class="form-control">
</div>
</div>
<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">
<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>
@@ -456,6 +494,194 @@
</div>
</div>
<!-- Cycle Pacing -->
<div class="mt-4 pt-4 border-t border-gray-200">
<h4 class="text-sm font-medium text-gray-900 mb-3">Cycle Pacing</h4>
<p class="text-sm text-gray-600 mb-3">How long one pass through the ticker lasts, and how many plugins it covers.</p>
<div class="form-group mb-4" id="setting-display-vegas_continuous_scroll" data-setting-key="display.vegas_scroll.continuous_scroll">
<label class="flex items-center">
<input type="checkbox"
id="vegas_continuous_scroll"
name="vegas_continuous_scroll"
{% if main_config.display.get('vegas_scroll', {}).get('continuous_scroll', True) %}checked{% endif %}
class="form-checkbox">
<span class="ml-2 text-sm text-gray-700">Scroll continuously between groups{{ ui.help_tip('Keep one endless strip, extending it with the next group of plugins as the scroll approaches the end, so they simply arrive from the right. Default: on.\nWith this off the ticker builds a fresh strip and swaps it in, which stops the motion, replaces everything at once and restarts with the screen already full — a freeze, a flash and a jump.', 'Continuous Scroll') }}</span>
</label>
</div>
<div class="form-group mb-4" id="setting-display-vegas_smooth_scroll" data-setting-key="display.vegas_scroll.smooth_scroll">
<label class="flex items-center">
<input type="checkbox"
id="vegas_smooth_scroll"
name="vegas_smooth_scroll"
{% if main_config.display.get('vegas_scroll', {}).get('smooth_scroll', True) %}checked{% endif %}
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>
</label>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div class="form-group" id="setting-display-vegas_extend_threshold_screens" data-setting-key="display.vegas_scroll.extend_threshold_screens">
<label for="vegas_extend_threshold_screens" class="block text-sm font-medium text-gray-700">Extend When (screens left){{ ui.help_tip('How much unscrolled content triggers loading the next group, measured in screen widths (1.010.0).\nDefault: 2. Higher loads earlier and leaves more slack, at the cost of holding more content in memory. Only applies when Continuous Scroll is on.', 'Extend Threshold') }}</label>
<input type="number"
id="vegas_extend_threshold_screens"
name="vegas_extend_threshold_screens"
value="{{ main_config.display.get('vegas_scroll', {}).get('extend_threshold_screens', 2.0) }}"
min="1"
max="10"
step="0.5"
class="form-control">
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-group" id="setting-display-vegas_plugins_per_cycle" data-setting-key="display.vegas_scroll.plugins_per_cycle">
<label for="vegas_plugins_per_cycle" class="block text-sm font-medium text-gray-700">Plugins Per Cycle{{ ui.help_tip('How many plugins are composed into one pass of the ticker (150).\nDefault: 6. Higher means more variety before the ticker restarts, and fewer recompose pauses. Lower means each plugin comes around sooner.', 'Plugins Per Cycle') }}</label>
<input type="number"
id="vegas_plugins_per_cycle"
name="vegas_plugins_per_cycle"
value="{{ main_config.display.get('vegas_scroll', {}).get('plugins_per_cycle', 6) }}"
min="1"
max="50"
class="form-control">
</div>
<div class="form-group" id="setting-display-vegas_overflow_mode" data-setting-key="display.vegas_scroll.overflow_mode">
<label for="vegas_overflow_mode" class="block text-sm font-medium text-gray-700">When A Plugin Is Too Wide{{ ui.help_tip('What to do when a plugin has more content than its width allowance.\nRotate through it: show a different slice each time round, so everything is seen eventually. Right for interchangeable items like news headlines, odds or stock prices.\nShow the start only: always display from the beginning and drop the rest. Right for ordered content — a league table that shows ranks 1-6 and then resumes at 7 two rotations later reads as out of order.\nDefault: rotate. Override for one plugin with vegas_overflow in its own settings.', 'Overflow Handling') }}</label>
<select id="vegas_overflow_mode" name="vegas_overflow_mode" class="form-control">
<option value="rotate" {% if main_config.display.get('vegas_scroll', {}).get('overflow_mode', 'rotate') == 'rotate' %}selected{% endif %}>Rotate through it (default)</option>
<option value="truncate" {% if main_config.display.get('vegas_scroll', {}).get('overflow_mode', 'rotate') == 'truncate' %}selected{% endif %}>Show the start only</option>
</select>
</div>
<div class="form-group" id="setting-display-vegas_max_plugin_width_ratio" data-setting-key="display.vegas_scroll.max_plugin_width_ratio">
<label for="vegas_max_plugin_width_ratio" class="block text-sm font-medium text-gray-700">Max Plugin Width (screens){{ ui.help_tip('Caps how much of one cycle a single plugin may occupy, measured in screen widths (020).\nDefault: 3. A long ticker such as a news feed or leaderboard is trimmed to this and the remainder shown on later cycles, so one plugin cannot hold the display for minutes. Set 0 for no limit.', 'Max Plugin Width') }}</label>
<input type="number"
id="vegas_max_plugin_width_ratio"
name="vegas_max_plugin_width_ratio"
value="{{ main_config.display.get('vegas_scroll', {}).get('max_plugin_width_ratio', 3.0) }}"
min="0"
max="20"
step="0.5"
class="form-control">
</div>
</div>
<div class="form-group mt-4" id="setting-display-vegas_dynamic_duration_enabled" data-setting-key="display.vegas_scroll.dynamic_duration_enabled">
<label class="flex items-center">
<input type="checkbox"
id="vegas_dynamic_duration_enabled"
name="vegas_dynamic_duration_enabled"
{% if main_config.display.get('vegas_scroll', {}).get('dynamic_duration_enabled', True) %}checked{% endif %}
class="form-checkbox">
<span class="ml-2 text-sm text-gray-700">Size cycle time to the content{{ ui.help_tip('When on, each cycle runs just long enough to scroll all its content past, clamped to the min and max below.\nWhen off, the max is always used. Default: on.', 'Dynamic Cycle Duration') }}</span>
</label>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-2">
<div class="form-group" id="setting-display-vegas_min_cycle_duration" data-setting-key="display.vegas_scroll.min_cycle_duration">
<label for="vegas_min_cycle_duration" class="block text-sm font-medium text-gray-700">Min Cycle Time (seconds){{ ui.help_tip('Shortest a single ticker pass may last (53600 s).\nDefault: 60.', 'Min Cycle Time') }}</label>
<input type="number"
id="vegas_min_cycle_duration"
name="vegas_min_cycle_duration"
value="{{ main_config.display.get('vegas_scroll', {}).get('min_cycle_duration', 60) }}"
min="5"
max="3600"
class="form-control">
</div>
<div class="form-group" id="setting-display-vegas_max_cycle_duration" data-setting-key="display.vegas_scroll.max_cycle_duration">
<label for="vegas_max_cycle_duration" class="block text-sm font-medium text-gray-700">Max Cycle Time (seconds){{ ui.help_tip('Longest a single ticker pass may last before it restarts with fresh content (103600 s).\nThis is the setting that caps total Vegas scroll time. Default: 240. Lower it if the ticker feels like it takes too long to come back around.', 'Max Cycle Time') }}</label>
<input type="number"
id="vegas_max_cycle_duration"
name="vegas_max_cycle_duration"
value="{{ main_config.display.get('vegas_scroll', {}).get('max_cycle_duration', 240) }}"
min="10"
max="3600"
class="form-control">
</div>
</div>
</div>
<!-- Dead Space -->
<div class="mt-4 pt-4 border-t border-gray-200">
<h4 class="text-sm font-medium text-gray-900 mb-3">Dead Space</h4>
<p class="text-sm text-gray-600 mb-3">Plugins that draw onto a full-screen canvas contribute all the empty space around their content. Trimming reclaims it so the ticker stays full.</p>
<div class="form-group mb-4" id="setting-display-vegas_auto_trim" data-setting-key="display.vegas_scroll.auto_trim">
<label class="flex items-center">
<input type="checkbox"
id="vegas_auto_trim"
name="vegas_auto_trim"
{% if main_config.display.get('vegas_scroll', {}).get('auto_trim', True) %}checked{% endif %}
class="form-checkbox">
<span class="ml-2 text-sm text-gray-700">Trim empty edges from plugin content{{ ui.help_tip('Crops blank columns from the left and right of each plugin block before it enters the ticker. Space between two pieces of content inside a block is left alone, so layouts are not altered. Default: on.', 'Auto Trim') }}</span>
</label>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-group" id="setting-display-vegas_content_padding" data-setting-key="display.vegas_scroll.content_padding">
<label for="vegas_content_padding" class="block text-sm font-medium text-gray-700">Content Padding (pixels){{ ui.help_tip('Blank columns kept either side of trimmed content, so it does not butt against the separator (0128 px).\nDefault: 8.', 'Content Padding') }}</label>
<input type="number"
id="vegas_content_padding"
name="vegas_content_padding"
value="{{ main_config.display.get('vegas_scroll', {}).get('content_padding', 8) }}"
min="0"
max="128"
class="form-control">
</div>
<div class="form-group" id="setting-display-vegas_lead_in_width" data-setting-key="display.vegas_scroll.lead_in_width">
<label for="vegas_lead_in_width" class="block text-sm font-medium text-gray-700">Lead-In Gap (pixels){{ ui.help_tip('Blank space before the first plugin of each cycle (02048 px).\nDefault: 0. Anything approaching your screen width reads as the display switching off at the start of every cycle.', 'Lead-In Gap') }}</label>
<input type="number"
id="vegas_lead_in_width"
name="vegas_lead_in_width"
value="{{ main_config.display.get('vegas_scroll', {}).get('lead_in_width', 0) }}"
min="0"
max="2048"
class="form-control">
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
<div class="form-group" id="setting-display-vegas_trim_threshold" data-setting-key="display.vegas_scroll.trim_threshold">
<label for="vegas_trim_threshold" class="block text-sm font-medium text-gray-700">Trim Threshold{{ ui.help_tip('How bright a pixel must be to count as content rather than empty space (0254).\nDefault: 10, which ignores the near-black noise left by image compression. Raise it if very dark artwork is being kept; lower it if dark detail is being cropped.', 'Trim Threshold') }}</label>
<input type="number"
id="vegas_trim_threshold"
name="vegas_trim_threshold"
value="{{ main_config.display.get('vegas_scroll', {}).get('trim_threshold', 10) }}"
min="0"
max="254"
class="form-control">
</div>
<div class="form-group" id="setting-display-vegas_min_plugin_width" data-setting-key="display.vegas_scroll.min_plugin_width">
<label for="vegas_min_plugin_width" class="block text-sm font-medium text-gray-700">Min Plugin Width (pixels){{ ui.help_tip('Plugin blocks narrower than this after trimming are skipped for that cycle (0512 px).\nDefault: 8. Raise it to hide plugins showing only a tiny placeholder such as "No Data" until they have real content.', 'Min Plugin Width') }}</label>
<input type="number"
id="vegas_min_plugin_width"
name="vegas_min_plugin_width"
value="{{ main_config.display.get('vegas_scroll', {}).get('min_plugin_width', 8) }}"
min="0"
max="512"
class="form-control">
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
<div class="form-group" id="setting-display-vegas_min_cut_gap" data-setting-key="display.vegas_scroll.min_cut_gap">
<label for="vegas_min_cut_gap" class="block text-sm font-medium text-gray-700">Min Cut Gap (pixels){{ ui.help_tip('When a plugin is too wide for its share of a cycle and has to be narrowed, the cut is only made where there is at least this much blank space (1128 px).\nDefault: 6. The gaps between letters are about 1px wide, so a smaller value lets a cut land inside a word and orphan its last letter into the next cycle. Raise it if cuts still land awkwardly. Continuous images such as maps have no gaps and are cut to fit regardless.', 'Min Cut Gap') }}</label>
<input type="number"
id="vegas_min_cut_gap"
name="vegas_min_cut_gap"
value="{{ main_config.display.get('vegas_scroll', {}).get('min_cut_gap', 6) }}"
min="1"
max="128"
class="form-control">
</div>
</div>
</div>
<!-- Plugin Order Section -->
<div class="mt-4 pt-4 border-t border-gray-200">
<h4 class="text-sm font-medium text-gray-900 mb-3">Plugin Order</h4>