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"), ]),