From 7d748361ab51aad5f936783da1ad5c93b794a697 Mon Sep 17 00:00:00 2001 From: jim kolpack Date: Fri, 9 Oct 2026 16:16:19 -0400 Subject: [PATCH] Fix jitter black border by rebuilding on Padding instead of Translate Re-enables the base jitter (disabled two commits ago while isolating the scanline work) with the actual fix, not another scale tweak -- two earlier attempts to fix the black border by bumping _glitch_rest_art's scale (breathing 1.15, then constant 1.15) both made it *worse* (680 and 532 black-corner hits across 200/133 frames, vs. 372 at a literal 1.0), which was the tell that scale was never the real problem. Root cause, confirmed via isolated pixel-sampled test renders: pixlet's animation.Transformation + Translate can't reveal content beyond what's visible at rest -- it only moves already-visible pixels, it doesn't pull in new ones -- so no scale on the wrapped content could have fixed a Translate-based shift. render.Padding, used elsewhere in this file specifically because it reliably *grows* a widget's declared canvas (scanlines, the zoom's own ART_SIZE vs ART_SLOT margin), doesn't have that limitation. _glitch_shift already existed using exactly that Padding approach and had already been validated safe ("no black border at +-2px, at both rest and peak zoom") -- it just wasn't what the current jitter was built on. Rebuilt _glitch_jitter as a render.Sequence of discrete states built from _glitch_shift, instead of one animation.Transformation with Translate keyframes. Sequence was deliberately avoided earlier in this app's history (it rebases frame index per state, confirmed in pixlet's own source, which previously desynced a *live*, frame-dependent zoom nested inside it) -- but that concern doesn't apply here: _glitch_rest_art (reverted to a plain locked-1.0 scale, since scale was never the issue) is static and frame-independent, so there's nothing left to desync. Verified: 0 black-corner hits across 199 frames (was ~370-680 under every earlier attempt), and payload is now far smaller too (54KB worst-case at strong zoom + random position, vs. 451KB before) -- Sequence's frame-level deduplication compresses much better than the continuously-interpolating Transformation did. --- subwave_nowplaying.star | 103 ++++++++++++++++------------------------ 1 file changed, 41 insertions(+), 62 deletions(-) diff --git a/subwave_nowplaying.star b/subwave_nowplaying.star index ed9419b..f5e61e8 100644 --- a/subwave_nowplaying.star +++ b/subwave_nowplaying.star @@ -438,38 +438,17 @@ def _glitch_rest_art(art_bytes, zoom_position, subsonic_id): # origin/zoom_position as the real zoom, so it doesn't visually jump # to a different part of the image when the glitch starts or ends. # - # Deliberately does NOT call _zoom_transformation(art_bytes, 1.15, - # ...): that function always *breathes* between 1.0 and whatever - # scale is passed in (it's the same "Ken Burns" animation the live - # zoom uses, over its own ART_ZOOM_DURATION cycle), so passing 1.15 - # still spends part of every cycle back down near the unsafe 1.0 - # floor -- confirmed empirically, it made the black border *worse* - # (680 black-corner hits across 200 frames, vs. 372 at a literal - # 1.0). What's needed here is a scale that's constantly 1.15, never - # breathing back down to 1.0, which this builds directly rather than - # reusing that function. - # - # At exactly scale=1.0, the outer Box auto-fits the oversized art to - # exactly ART_SLOT with no overflow left over (see _zoom_transformation's - # own comment for that behavior), leaving zero spare margin for the - # jitter's shift to reveal and producing a real, on-device black - # border (confirmed: every corner checked across 133 frames of a real - # render, not an isolated edge case). A constant 1.15 gives enough - # overflow to safely cover the jitter's +-1px shift while staying - # close enough to 1.0 to still read as "the full picture," not a - # noticeable zoom. - art = render.Image(src = art_bytes, width = ART_SIZE, height = ART_SIZE) - origin_x, origin_y = _zoom_spot(zoom_position, subsonic_id) - static_scale = animation.Transformation( - child = art, - duration = 1, - origin = animation.Origin(origin_x, origin_y), - keyframes = [ - animation.Keyframe(percentage = 0.0, transforms = [animation.Scale(1.15, 1.15)]), - animation.Keyframe(percentage = 1.0, transforms = [animation.Scale(1.15, 1.15)]), - ], - ) - return static_scale + # Scale is locked at 1.0 (via _zoom_transformation's own top_scale = + # max(zoom_scale, 1.0), so passing 1.0 forces a constant Scale(1.0, + # 1.0) with no breathing at all) -- this is exactly the "rest" state + # _glitch_shift's own margin was already validated against (see its + # comment below). A black border was seen here earlier, but it traced + # to the *shift* mechanism (animation.Transformation + Translate, + # since replaced -- see _glitch_jitter), not to this scale; two + # different attempts to fix it by bumping the scale (1.15 breathing, + # then a constant 1.15) each made the border worse, not better, which + # was the tell that scale was never the real problem. + return _zoom_transformation(art_bytes, 1.0, zoom_position, subsonic_id) def _glitch_states_for_cycle(cycle_index): # Rotate through GLITCH_STATE_POOL instead of slicing (which doesn't @@ -512,23 +491,28 @@ def _glitch_base_jitter(zoom, shift_x): return render.Box(width = ART_SLOT, height = ART_SLOT, child = _glitch_shift(zoom, shift_x)) def _glitch_jitter(zoom): - # Built as ONE animation.Transformation (Translate-only keyframes) - # wrapping the real `zoom`, not a render.Sequence of separately-held - # states (the previous approach). That turned out to be a real bug, - # not just a tuning issue: Sequence.Paint rebases the frame index to - # 0 for whichever child is currently playing (confirmed in pixlet's - # own source -- `c.Paint(dc, bounds, frameIdx-fc)`), so every time the - # jitter switched to a new held state, the *embedded* zoom saw a - # near-zero frame index and reset toward the start of its own - # ART_ZOOM_DURATION cycle before snapping back on the next state -- - # exactly the "big size jumps, not at the same zoom level as the art" - # that were reported. animation.Transformation does not rebase frame - # index for its child (`t.Child.Paint(dc, bounds, frameIdx)` -- same - # frameIdx in and out), so nesting it around `zoom` instead keeps the - # zoom perfectly in sync with the real art alongside it. - total_ticks = GLITCH_REPEATS * _GLITCH_CYCLE_TICKS - keyframes = [] - tick = 0 + # Built as a render.Sequence of discrete shifted states, each using + # _glitch_shift (Padding-based), not animation.Transformation + + # Translate (the previous approach). That seemed reasonable but + # produced a real, confirmed-on-device black border that no amount of + # scale adjustment on `zoom` could fix -- root-caused via isolated, + # pixel-sampled test renders to a more fundamental pixlet limitation: + # Transformation + Translate can't reveal content beyond what's + # visible at rest, it only moves already-visible pixels around. + # Padding genuinely grows the declared canvas instead, which is + # exactly the mechanism _glitch_shift already uses and had already + # validated as safe ("no black border at +-2px, at both rest and peak + # zoom") before this ever used Translate. + # + # A much earlier version of this file *did* use render.Sequence for + # the jitter and was abandoned for a real bug -- but that bug was + # Sequence.Paint rebasing the frame index to 0 for whichever child is + # currently playing (confirmed in pixlet's own source -- + # `c.Paint(dc, bounds, frameIdx-fc)`), which desynced the *live*, + # frame-dependent zoom that version wrapped. That no longer applies: + # `zoom` here is _glitch_rest_art's static, non-animated image, so + # there's nothing frame-dependent left for Sequence to desync. + children = [] for cycle_index in range(GLITCH_REPEATS): for _ in range(GLITCH_STATES_PER_CYCLE): # Capped to +-1px -- a full-frame shift reads very differently @@ -539,19 +523,14 @@ def _glitch_jitter(zoom): # cancel out, e.g. -2 and 2, which silently zeroed out the # jitter in about half the states when this was tried before). shift = random.number(0, 2) - 1 # random.number requires min >= 0 - keyframes.append(animation.Keyframe( - percentage = float(tick) / total_ticks, - transforms = [animation.Translate(shift, 0)], - )) - tick += GLITCH_HOLD_TICKS - keyframes.append(animation.Keyframe( - percentage = float(tick) / total_ticks, - transforms = [animation.Translate(0, 0)], - )) - tick += GLITCH_PAUSE_TICKS + shifted = render.Box(width = ART_SLOT, height = ART_SLOT, child = _glitch_shift(zoom, shift)) + for _ in range(GLITCH_HOLD_TICKS): + children.append(shifted) + rest = render.Box(width = ART_SLOT, height = ART_SLOT, child = zoom) + for _ in range(GLITCH_PAUSE_TICKS): + children.append(rest) - jittered = animation.Transformation(child = zoom, duration = total_ticks, keyframes = keyframes) - return render.Box(width = ART_SLOT, height = ART_SLOT, child = jittered) + return render.Sequence(children = children) # Line count and spacing are derived from each other, not picked # independently: the small-sawtooth sweep below only loops seamlessly @@ -682,7 +661,7 @@ def _glitch_effect(zoom): width = ART_SLOT, height = ART_SLOT, child = render.Stack(children = [ - # _glitch_jitter(zoom), # disabled -- isolating scanlines for now + _glitch_jitter(zoom), _glitch_scanlines(), _glitch_corner_ticks("#00000080"), ]),