From e51c2bd4229a5917d9c36dda04fc69ec390ea3e7 Mon Sep 17 00:00:00 2001 From: jim kolpack Date: Thu, 8 Oct 2026 19:02:37 -0400 Subject: [PATCH] Jitter the underlying art in sync with the RGB-split bands SUB/WAVE's original effect shifts the whole cover frame along with the color-split bands, not just the bands moving over a static image underneath. Added that: each burst state now includes a shifted (but untinted, uncropped) full copy of the art beneath the two RGB-split bands, nudged by the average of the two bands' own shift values. Applying the shift to the already-cropped ART_SLOT x ART_SLOT image (the way the bands used to work) would have exposed a blank edge -- the same black-border problem from much earlier in this project. Instead, the shift is applied to the *raw* zoom Transformation before its final crop, reusing the same oversized margin the zoom animation itself relies on. Confirmed safe both in an isolated test (+-2px at rest and peak zoom, no border) and by scanning all 200 frames of a real worst-case render (strong zoom, random position) for black/transparent corner pixels -- none found. This required exposing the zoom Transformation itself to the glitch code instead of just the final cropped image, so _zoomed_art became _zoom_transformation (returns the raw widget; callers wrap it in the ART_SLOT Box themselves). Payload note: worst case (strong zoom + random position) measured 428KB, up from ~220KB before this change but still under the 460KB device limit -- margin is noticeably thinner now (~7%) than it's been recently, worth keeping an eye on if more effects get added later. --- subwave_nowplaying.star | 54 +++++++++++++++++++++++++++++++---------- 1 file changed, 41 insertions(+), 13 deletions(-) diff --git a/subwave_nowplaying.star b/subwave_nowplaying.star index 651c1a2..d615941 100644 --- a/subwave_nowplaying.star +++ b/subwave_nowplaying.star @@ -284,14 +284,19 @@ ART_SLOT = 32 ART_ZOOM_DURATION = 200 # 10s round-trip (5s in, 5s out) at the default 50ms/tick def _art_widget(art_bytes, zoom_scale, zoom_position, subsonic_id, show_static): - cropped = _zoomed_art(art_bytes, zoom_scale, zoom_position, subsonic_id) + zoom = _zoom_transformation(art_bytes, zoom_scale, zoom_position, subsonic_id) + cropped = render.Box(width = ART_SLOT, height = ART_SLOT, child = zoom) if not show_static: return cropped - return render.Stack(children = [cropped, _glitch_effect(cropped)]) + # Passing the raw (pre-crop) `zoom` down to the glitch effect, not the + # already-flattened `cropped`, so it can jitter the art's position + # using the same oversized margin the zoom animation itself relies on + # -- see _glitch_band and _glitch_base_jitter for why that matters. + return render.Stack(children = [cropped, _glitch_effect(zoom)]) -def _zoomed_art(art_bytes, zoom_scale, zoom_position, subsonic_id): +def _zoom_transformation(art_bytes, zoom_scale, zoom_position, subsonic_id): art = render.Image(src = art_bytes, width = ART_SIZE, height = ART_SIZE) # Always route through animation.Transformation, even when zoom is off @@ -338,7 +343,7 @@ def _zoomed_art(art_bytes, zoom_scale, zoom_position, subsonic_id): ), ], ) - return render.Box(width = ART_SLOT, height = ART_SLOT, child = zoom) + return zoom # Modeled on SUB/WAVE's own web UI, which applies a track-transition # effect to the cover art (found via its compiled CSS/JS, not written down @@ -421,20 +426,37 @@ def _glitch_states_for_cycle(cycle_index): start = (cycle_index * GLITCH_STATES_PER_CYCLE) % pool_size return [GLITCH_STATE_POOL[(start + i) % pool_size] for i in range(GLITCH_STATES_PER_CYCLE)] -def _glitch_band(zoomed_art, top, height, shift_x, hue_change): - tinted = filter.Hue(child = zoomed_art, change = hue_change) +def _glitch_shift(zoom, shift_x): + # Applied to the *raw* zoom Transformation (not an already-cropped + # ART_SLOT x ART_SLOT image) and only clipped to ART_SLOT afterward, + # so a horizontal nudge reveals more of the same oversized margin the + # zoom animation itself relies on, rather than exposing a blank edge + # with nothing behind it -- confirmed empirically (no black border at + # +-2px, at both rest and peak zoom) before building this in. + return render.Padding(pad = (max(0, shift_x), 0, max(0, -shift_x), 0), child = zoom) + +def _glitch_band(zoom, top, height, shift_x, hue_change): + tinted = filter.Hue(child = _glitch_shift(zoom, shift_x), change = hue_change) band = render.Padding( pad = (0, top, 0, max(0, ART_SLOT - top - height)), child = render.Box(width = ART_SLOT, height = height, child = tinted), ) - shifted = render.Padding(pad = (max(0, shift_x), 0, max(0, -shift_x), 0), child = band) # render.Padding *grows* its child's declared size by the pad amount # rather than nudging content within a fixed frame -- without this # outer Box to clip it back down to exactly ART_SLOT, the horizontal # shift above made this (and everything stacked with it) wider than # the 32px art slot, bleeding into the text column next to it. - return render.Box(width = ART_SLOT, height = ART_SLOT, child = shifted) + return render.Box(width = ART_SLOT, height = ART_SLOT, child = band) + +def _glitch_base_jitter(zoom, shift_x): + # The art itself jittering in sync with the RGB-split bands (not just + # the tinted bands moving over a static image underneath) -- modeled + # on the original SUB/WAVE effect, which shifts the whole cover frame + # along with the color split. Uses the same safe raw-zoom shift + # technique as the bands above, just without the hue tint or height + # crop, so it reads as the full image nudging rather than a band. + return render.Box(width = ART_SLOT, height = ART_SLOT, child = _glitch_shift(zoom, shift_x)) def _glitch_hold(child, duration): # A no-op Transformation purely to pin a Sequence child's FrameCount @@ -452,15 +474,21 @@ def _glitch_hold(child, duration): ], ) -def _glitch_jitter(zoomed_art): +def _glitch_jitter(zoom): pause = _glitch_hold(render.Box(width = ART_SLOT, height = ART_SLOT), GLITCH_PAUSE_TICKS) sequence = [] for cycle_index in range(GLITCH_REPEATS): for top1, h1, shift1, hue1, top2, h2, shift2, hue2 in _glitch_states_for_cycle(cycle_index): + # The art itself nudges along with the bands, not just the + # bands moving over a static image -- splitting the difference + # between the two bands' own shifts rather than adding a + # separate value to tune per state. + base_shift = int((shift1 + shift2) / 2.0) state = render.Stack(children = [ - _glitch_band(zoomed_art, top1, h1, shift1, hue1), - _glitch_band(zoomed_art, top2, h2, shift2, hue2), + _glitch_base_jitter(zoom, base_shift), + _glitch_band(zoom, top1, h1, shift1, hue1), + _glitch_band(zoom, top2, h2, shift2, hue2), ]) sequence.append(_glitch_hold(state, GLITCH_HOLD_TICKS)) sequence.append(pause) @@ -525,12 +553,12 @@ def _glitch_corner_ticks(color): ), ) -def _glitch_effect(zoomed_art): +def _glitch_effect(zoom): return render.Box( width = ART_SLOT, height = ART_SLOT, child = render.Stack(children = [ - _glitch_jitter(zoomed_art), + _glitch_jitter(zoom), _glitch_scanlines(), _glitch_corner_ticks("#33ff9980"), ]),