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.
This commit is contained in:
poprhythm committed 2026-10-09 16:16:19 -04:00
1 parent 927a23e557
commit 7d748361ab
1 file changed
+41 -62
+41 -62
View File
@@ -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 # 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. # to a different part of the image when the glitch starts or ends.
# #
# Deliberately does NOT call _zoom_transformation(art_bytes, 1.15, # Scale is locked at 1.0 (via _zoom_transformation's own top_scale =
# ...): that function always *breathes* between 1.0 and whatever # max(zoom_scale, 1.0), so passing 1.0 forces a constant Scale(1.0,
# scale is passed in (it's the same "Ken Burns" animation the live # 1.0) with no breathing at all) -- this is exactly the "rest" state
# zoom uses, over its own ART_ZOOM_DURATION cycle), so passing 1.15 # _glitch_shift's own margin was already validated against (see its
# still spends part of every cycle back down near the unsafe 1.0 # comment below). A black border was seen here earlier, but it traced
# floor -- confirmed empirically, it made the black border *worse* # to the *shift* mechanism (animation.Transformation + Translate,
# (680 black-corner hits across 200 frames, vs. 372 at a literal # since replaced -- see _glitch_jitter), not to this scale; two
# 1.0). What's needed here is a scale that's constantly 1.15, never # different attempts to fix it by bumping the scale (1.15 breathing,
# breathing back down to 1.0, which this builds directly rather than # then a constant 1.15) each made the border worse, not better, which
# reusing that function. # was the tell that scale was never the real problem.
# return _zoom_transformation(art_bytes, 1.0, zoom_position, subsonic_id)
# 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
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
@@ -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)) return render.Box(width = ART_SLOT, height = ART_SLOT, child = _glitch_shift(zoom, shift_x))
def _glitch_jitter(zoom): def _glitch_jitter(zoom):
# Built as ONE animation.Transformation (Translate-only keyframes) # Built as a render.Sequence of discrete shifted states, each using
# wrapping the real `zoom`, not a render.Sequence of separately-held # _glitch_shift (Padding-based), not animation.Transformation +
# states (the previous approach). That turned out to be a real bug, # Translate (the previous approach). That seemed reasonable but
# not just a tuning issue: Sequence.Paint rebases the frame index to # produced a real, confirmed-on-device black border that no amount of
# 0 for whichever child is currently playing (confirmed in pixlet's # scale adjustment on `zoom` could fix -- root-caused via isolated,
# own source -- `c.Paint(dc, bounds, frameIdx-fc)`), so every time the # pixel-sampled test renders to a more fundamental pixlet limitation:
# jitter switched to a new held state, the *embedded* zoom saw a # Transformation + Translate can't reveal content beyond what's
# near-zero frame index and reset toward the start of its own # visible at rest, it only moves already-visible pixels around.
# ART_ZOOM_DURATION cycle before snapping back on the next state -- # Padding genuinely grows the declared canvas instead, which is
# exactly the "big size jumps, not at the same zoom level as the art" # exactly the mechanism _glitch_shift already uses and had already
# that were reported. animation.Transformation does not rebase frame # validated as safe ("no black border at +-2px, at both rest and peak
# index for its child (`t.Child.Paint(dc, bounds, frameIdx)` -- same # zoom") before this ever used Translate.
# frameIdx in and out), so nesting it around `zoom` instead keeps the #
# zoom perfectly in sync with the real art alongside it. # A much earlier version of this file *did* use render.Sequence for
total_ticks = GLITCH_REPEATS * _GLITCH_CYCLE_TICKS # the jitter and was abandoned for a real bug -- but that bug was
keyframes = [] # Sequence.Paint rebasing the frame index to 0 for whichever child is
tick = 0 # 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 cycle_index in range(GLITCH_REPEATS):
for _ in range(GLITCH_STATES_PER_CYCLE): for _ in range(GLITCH_STATES_PER_CYCLE):
# Capped to +-1px -- a full-frame shift reads very differently # 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 # cancel out, e.g. -2 and 2, which silently zeroed out the
# jitter in about half the states when this was tried before). # jitter in about half the states when this was tried before).
shift = random.number(0, 2) - 1 # random.number requires min >= 0 shift = random.number(0, 2) - 1 # random.number requires min >= 0
keyframes.append(animation.Keyframe( shifted = render.Box(width = ART_SLOT, height = ART_SLOT, child = _glitch_shift(zoom, shift))
percentage = float(tick) / total_ticks, for _ in range(GLITCH_HOLD_TICKS):
transforms = [animation.Translate(shift, 0)], children.append(shifted)
)) rest = render.Box(width = ART_SLOT, height = ART_SLOT, child = zoom)
tick += GLITCH_HOLD_TICKS for _ in range(GLITCH_PAUSE_TICKS):
keyframes.append(animation.Keyframe( children.append(rest)
percentage = float(tick) / total_ticks,
transforms = [animation.Translate(0, 0)],
))
tick += GLITCH_PAUSE_TICKS
jittered = animation.Transformation(child = zoom, duration = total_ticks, keyframes = keyframes) return render.Sequence(children = children)
return render.Box(width = ART_SLOT, height = ART_SLOT, child = jittered)
# Line count and spacing are derived from each other, not picked # Line count and spacing are derived from each other, not picked
# independently: the small-sawtooth sweep below only loops seamlessly # independently: the small-sawtooth sweep below only loops seamlessly
@@ -682,7 +661,7 @@ def _glitch_effect(zoom):
width = ART_SLOT, width = ART_SLOT,
height = ART_SLOT, height = ART_SLOT,
child = render.Stack(children = [ child = render.Stack(children = [
# _glitch_jitter(zoom), # disabled -- isolating scanlines for now _glitch_jitter(zoom),
_glitch_scanlines(), _glitch_scanlines(),
_glitch_corner_ticks("#00000080"), _glitch_corner_ticks("#00000080"),
]), ]),
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.