diff --git a/subwave_nowplaying.star b/subwave_nowplaying.star index 02a0613..e87a1dc 100644 --- a/subwave_nowplaying.star +++ b/subwave_nowplaying.star @@ -296,11 +296,15 @@ def _art_widget(art_bytes, zoom_scale, zoom_position, subsonic_id, show_static): if not show_static: return 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)]) + # The glitch segment gets its own non-animated, full-resolution + # (scale locked at 1.0) rendering of the art, rather than the live + # `zoom` above -- simpler than keeping the jitter in sync with + # wherever the real zoom's 10s cycle currently is (see + # _glitch_rest_art for why that was worth avoiding). The live zoom on + # the base `cropped` layer underneath keeps running throughout; once + # the glitch window ends, that's what's left showing. + rest_art = _glitch_rest_art(art_bytes, zoom_position, subsonic_id) + return render.Stack(children = [cropped, _glitch_effect(rest_art)]) def _zoom_transformation(art_bytes, zoom_scale, zoom_position, subsonic_id): art = render.Image(src = art_bytes, width = ART_SIZE, height = ART_SIZE) @@ -424,6 +428,18 @@ _GLITCH_CYCLE_TICKS = GLITCH_STATES_PER_CYCLE * GLITCH_HOLD_TICKS + GLITCH_PAUSE _GLITCH_TICKS_PER_SECOND = 20 # 1000ms / 50ms-per-tick GLITCH_REPEATS = int(GLITCH_ACTIVE_SECONDS * _GLITCH_TICKS_PER_SECOND / _GLITCH_CYCLE_TICKS) + 1 +def _glitch_rest_art(art_bytes, zoom_position, subsonic_id): + # A separate, non-animated rendering of the art (scale locked at 1.0 + # throughout, via _zoom_transformation's own zoom_scale argument) for + # the glitch segment to jitter, instead of reusing the live `zoom` + # that's actually mid-cycle on the base layer underneath. Simpler than + # keeping the jitter synced to wherever that 10s cycle currently is -- + # there's nothing here that depends on frame position, so nothing to + # desync regardless of how the jitter's own timing is built. Same + # 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. + 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 # wrap around in Starlark), so each cycle's window of states can @@ -464,47 +480,47 @@ def _glitch_base_jitter(zoom, shift_x): # 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 - # to `duration` ticks, so render.Sequence holds on it for that long - # before advancing to the next child -- Sequence has no other way to - # control how long a given child plays. - return animation.Transformation( - child = child, - duration = duration, - width = ART_SLOT, - height = ART_SLOT, - keyframes = [ - animation.Keyframe(percentage = 0.0, transforms = []), - animation.Keyframe(percentage = 1.0, transforms = []), - ], - ) - def _glitch_jitter(zoom): - pause = _glitch_hold(render.Box(width = ART_SLOT, height = ART_SLOT), GLITCH_PAUSE_TICKS) - - sequence = [] + # 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 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. Capped to +-1px -- a - # full-frame shift reads very differently than a thin band - # shifting, so it stays subtle. Randomized rather than a fixed - # alternating +1/-1 (which read as too mechanically regular, - # a predictable back-and-forth) or derived from shift1/shift2 - # (several pool entries have those nearly or exactly cancel - # out, e.g. -2 and 2, which silently zeroed out the jitter in - # about half the states). - base_shift = random.number(0, 2) - 1 # random.number requires min >= 0 - state = render.Stack(children = [ - _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) + for _ in range(GLITCH_STATES_PER_CYCLE): + # Capped to +-1px -- a full-frame shift reads very differently + # than a thin band shifting, so it stays subtle. Randomized + # rather than a fixed alternating +1/-1 (too mechanically + # regular) or derived from shift1/shift2 (several + # GLITCH_STATE_POOL entries have those nearly or exactly + # 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, shift)], + )) + tick += GLITCH_HOLD_TICKS + keyframes.append(animation.Keyframe( + percentage = float(tick) / total_ticks, + transforms = [animation.Translate(0, 0)], + )) + tick += GLITCH_PAUSE_TICKS - return render.Sequence(children = sequence) + jittered = animation.Transformation(child = zoom, duration = total_ticks, keyframes = keyframes) + return render.Box(width = ART_SLOT, height = ART_SLOT, child = jittered) def _glitch_scanlines(): # render.Sequence doesn't loop (it just stops painting once its total