Give the glitch segment its own non-animated, full-res art

Root-caused the "big size jumps, not at the same zoom level as the art"
report: the jitter embedded the live `zoom` Transformation inside a
render.Sequence of separately-held states, and 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)). Every time
the jitter switched to a new held state (every ~100-150ms), the embedded
zoom saw a near-zero frame index and reset toward the start of its own
10s cycle before snapping back on the next state.

Rather than keep the jitter perfectly synced to the live zoom's current
position (tried via a single Translate-keyframed Transformation instead
of Sequence, which doesn't rebase frame index for its child, and does
work), went with the simpler fix requested instead: the glitch segment
now jitters its own separate, non-animated rendering of the art (scale
locked at 1.0 throughout, via _zoom_transformation's own zoom_scale
argument) rather than the live mid-cycle zoom. Same origin/zoom_position,
so it doesn't jump to a different part of the image when the glitch
starts or ends; the live zoom on the base layer underneath keeps
running throughout regardless, and is what's left showing once the
glitch window ends.

Verified: renders cleanly, no black border across all 109 frames of a
worst-case render (strong zoom, random position, all four corners), and
payload dropped way down (121.6KB worst case, vs. the 460KB device
limit) since the rest-art layer no longer depends on live zoom state,
giving WebP much more to dedupe.
This commit is contained in:
poprhythm committed 2026-10-09 15:31:56 -04:00
1 parent 6f9b6ce838
commit 4cf6c7d506
1 file changed
+59 -43
+59 -43
View File
@@ -296,11 +296,15 @@ def _art_widget(art_bytes, zoom_scale, zoom_position, subsonic_id, show_static):
if not show_static: if not show_static:
return cropped return cropped
# Passing the raw (pre-crop) `zoom` down to the glitch effect, not the # The glitch segment gets its own non-animated, full-resolution
# already-flattened `cropped`, so it can jitter the art's position # (scale locked at 1.0) rendering of the art, rather than the live
# using the same oversized margin the zoom animation itself relies on # `zoom` above -- simpler than keeping the jitter in sync with
# -- see _glitch_band and _glitch_base_jitter for why that matters. # wherever the real zoom's 10s cycle currently is (see
return render.Stack(children = [cropped, _glitch_effect(zoom)]) # _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): def _zoom_transformation(art_bytes, zoom_scale, zoom_position, subsonic_id):
art = render.Image(src = art_bytes, width = ART_SIZE, height = ART_SIZE) 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_TICKS_PER_SECOND = 20 # 1000ms / 50ms-per-tick
GLITCH_REPEATS = int(GLITCH_ACTIVE_SECONDS * _GLITCH_TICKS_PER_SECOND / _GLITCH_CYCLE_TICKS) + 1 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): def _glitch_states_for_cycle(cycle_index):
# Rotate through GLITCH_STATE_POOL instead of slicing (which doesn't # Rotate through GLITCH_STATE_POOL instead of slicing (which doesn't
# wrap around in Starlark), so each cycle's window of states can # 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. # 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)) 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): def _glitch_jitter(zoom):
pause = _glitch_hold(render.Box(width = ART_SLOT, height = ART_SLOT), GLITCH_PAUSE_TICKS) # Built as ONE animation.Transformation (Translate-only keyframes)
# wrapping the real `zoom`, not a render.Sequence of separately-held
sequence = [] # 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 cycle_index in range(GLITCH_REPEATS):
for top1, h1, shift1, hue1, top2, h2, shift2, hue2 in _glitch_states_for_cycle(cycle_index): for _ in range(GLITCH_STATES_PER_CYCLE):
# The art itself nudges along with the bands, not just the # Capped to +-1px -- a full-frame shift reads very differently
# bands moving over a static image. Capped to +-1px -- a # than a thin band shifting, so it stays subtle. Randomized
# full-frame shift reads very differently than a thin band # rather than a fixed alternating +1/-1 (too mechanically
# shifting, so it stays subtle. Randomized rather than a fixed # regular) or derived from shift1/shift2 (several
# alternating +1/-1 (which read as too mechanically regular, # GLITCH_STATE_POOL entries have those nearly or exactly
# a predictable back-and-forth) or derived from shift1/shift2 # cancel out, e.g. -2 and 2, which silently zeroed out the
# (several pool entries have those nearly or exactly cancel # jitter in about half the states when this was tried before).
# out, e.g. -2 and 2, which silently zeroed out the jitter in shift = random.number(0, 2) - 1 # random.number requires min >= 0
# about half the states). keyframes.append(animation.Keyframe(
base_shift = random.number(0, 2) - 1 # random.number requires min >= 0 percentage = float(tick) / total_ticks,
state = render.Stack(children = [ transforms = [animation.Translate(shift, shift)],
_glitch_base_jitter(zoom, base_shift), ))
# _glitch_band(zoom, top1, h1, shift1, hue1), tick += GLITCH_HOLD_TICKS
# _glitch_band(zoom, top2, h2, shift2, hue2), keyframes.append(animation.Keyframe(
]) percentage = float(tick) / total_ticks,
sequence.append(_glitch_hold(state, GLITCH_HOLD_TICKS)) transforms = [animation.Translate(0, 0)],
sequence.append(pause) ))
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(): def _glitch_scanlines():
# render.Sequence doesn't loop (it just stops painting once its total # render.Sequence doesn't loop (it just stops painting once its total
Reference in new issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.