Fix the periodic zoom pop at its root: disable Translate's pixel rounding

Root-caused the "pop" from the Scale+Translate centered zoom (reverted a
few commits back) to pixlet's own source: render/animation/translate.go
rounds a Translate's x/y to the nearest whole pixel every frame by
default (rounding = "round"), while animation/scale.go never rounds
Scale. A continuously-interpolating Scale combined with a rounded
Translate holds still for several frames, then jumps a whole pixel at
once -- a real, confirmed pixlet behavior, not a flaw in this app's
formula.

Transformation accepts a `rounding` parameter; passing "none" disables
the snapping. Confirmed via frame-by-frame pixel-delta analysis: the
per-frame change is now even throughout the animation (consistently
~900 AE out of 1024 possible, every frame) instead of long near-zero
stretches punctuated by sudden full-frame jumps.

Re-enables the centered zoom (Scale + Translate) that was reverted for
jerkiness, now smooth. ZOOM_LEVELS["strong"] stays capped at 1.45 --
that black-border ceiling is unrelated to rounding and still applies.
Verified against the live station: no pop, no black border at any zoom
level, payload ~370KB (up from ~220-320KB, since rounding="none" means
WebP can dedupe fewer near-identical frames) but still well under the
460KB device limit.
This commit is contained in:
poprhythm committed 2026-10-02 22:03:05 -04:00
1 parent 9f63adbfbf
commit 4a2b3caac8
3 files changed
+73 -47

No files matched your search

+24 -14
View File
@@ -46,20 +46,30 @@ app's config when adding it to a device.
attempts produced a real, on-device black border; see the fuller
history below.
`animation.Scale`'s pivot defaults to the top-left corner (confirmed
empirically — an explicit `origin` parameter has no effect at all), so
zooming in crops a bit more off the bottom/right than the top/left. A
centered version (pairing Scale with a computed `animation.Translate`)
was built and visually worked, but combining Scale with Translate turned
out to make pixlet periodically re-rasterize the whole image with a
visible "pop" every ~0.7-1s — confirmed with both WebP and GIF output,
independent of zoom amplitude, so it's a property of pixlet's animation
engine, not this app's math. Scale *alone* has none of that popping.
Given the choice between a subtle, constant crop asymmetry and a
periodic visible pop, this app accepts the asymmetry. (`dev/calibration_test.star`
has a working centered version if anyone wants to revisit this trade-off.)
Verified payload stays well under the 460KB device limit at every zoom
level, measured against the live station.
`animation.Scale`'s pivot defaults to the *center* of the widget per
pixlet's own docs, so Scale alone is already close to centered — just
slightly skewed toward the top-left as the zoom amount increases.
Paired with a computed `animation.Translate` to cancel out that residual
skew. Combining Scale with Translate at all was tried once before and
reverted after it produced a periodic rendering "pop" roughly every
0.7-1s — confirmed with both WebP and GIF output, independent of zoom
amplitude. Root-caused to pixlet's own source
(`render/animation/translate.go`): a Translate's x/y are rounded to the
nearest whole pixel every frame by default, while Scale is never
rounded, so a continuously-changing Scale combined with a rounded
Translate holds still for several frames and then jumps a whole pixel
at once. Passing `rounding = "none"` on the `Transformation` disables
that snapping and fixes it — confirmed via frame-by-frame pixel-delta
analysis (the amount of change is now even from frame to frame, instead
of long still stretches punctuated by sudden jumps). "strong" is capped
at 1.45 (not pushed further): above ~1.46 the centered crop starts
revealing a real black border in the bottom-right corner, verified with
the calibration tool — this is the safe ceiling, not an arbitrary
choice. Verified payload stays well under the 460KB device limit at
every zoom level, measured against the live station (though with
`rounding = "none"`, WebP can no longer dedupe as many near-identical
frames, so payloads run larger than the "pop" version did — ~370KB
vs. ~220-320KB — still comfortably within budget).
**Why a loop, not a one-shot reveal, and why it's short:** a one-way
version looked jumpy in practice — since the app re-renders on
+21 -15
View File
@@ -1,7 +1,8 @@
"""
Dev tool, not part of the deployed app: renders dev/calibration.png through
the exact same zoom logic as _art_widget in subwave_nowplaying.star, so you
can visually check how much margin gets cropped at rest and at full zoom.
can visually check how much margin gets cropped at rest and at full zoom,
and whether the crop is even on all sides.
calibration.png is a 360x360 grid: concentric 10%-wide color rings (magenta
outermost, then yellow, then cyan) plus four corner markers (red TL, green
@@ -14,17 +15,20 @@ Usage:
Keep ART_SIZE/ART_SLOT/the zoom math below in sync with the real
_art_widget if that ever changes.
Note: a centered (Scale + Translate) version of this zoom was tried and
visually fixed the top-left crop bias below, but combining Scale with
Translate causes pixlet to periodically re-rasterize the whole image with
a visible "pop" -- confirmed with both WebP and GIF output, independent of
zoom amplitude, so it's pixlet's animation engine, not app-level math. This
tool (and the real app) stick to Scale alone -- a real but minor crop-bias
cosmetic issue, instead of the pop a centered zoom introduces. If you want
to re-investigate centering, add animation.Translate back in here first
and compare frame-to-frame pixel deltas (see git history around the
"Center the album art zoom" / "Revert jerky zoom fix" commits) before
touching the real app.
History, if this ever needs revisiting: a Scale+Translate centered zoom
was tried, then reverted after it produced a periodic rendering "pop"
(confirmed with both WebP and GIF output, independent of zoom amplitude).
Root cause, found in pixlet's own source
(render/animation/translate.go): a Translate's x/y are rounded to the
nearest whole pixel every frame by default (`rounding = "round"`), while
Scale is never rounded -- so a continuously-interpolating Scale combined
with a rounded Translate holds still for several frames, then jumps a
whole pixel at once. Passing `rounding = "none"` on the Transformation
(see below) disables that snapping and fixed it -- confirmed via
frame-by-frame pixel-delta analysis (even per-frame change throughout,
instead of long still stretches punctuated by sudden jumps). If a future
change reintroduces jerkiness, check whether `rounding = "none"` is still
present before suspecting the shift formula.
"""
load("render.star", "render")
@@ -38,13 +42,15 @@ ART_ZOOM_DURATION = 200
def _art_widget(art_bytes, zoom_scale):
art = render.Image(src = art_bytes, width = ART_SIZE, height = ART_SIZE)
top_scale = max(zoom_scale, 1.0)
shift = (1.0 - top_scale) * (ART_SIZE / 2.0)
zoom = animation.Transformation(
child = art,
duration = ART_ZOOM_DURATION,
rounding = "none",
keyframes = [
animation.Keyframe(percentage = 0.0, transforms = [animation.Scale(1.0, 1.0)]),
animation.Keyframe(percentage = 0.5, transforms = [animation.Scale(top_scale, top_scale)], curve = "ease_in_out"),
animation.Keyframe(percentage = 1.0, transforms = [animation.Scale(1.0, 1.0)], curve = "ease_in_out"),
animation.Keyframe(percentage = 0.0, transforms = [animation.Scale(1.0, 1.0), animation.Translate(0, 0)]),
animation.Keyframe(percentage = 0.5, transforms = [animation.Scale(top_scale, top_scale), animation.Translate(shift, shift)], curve = "ease_in_out"),
animation.Keyframe(percentage = 1.0, transforms = [animation.Scale(1.0, 1.0), animation.Translate(0, 0)], curve = "ease_in_out"),
],
)
return render.Box(width = ART_SLOT, height = ART_SLOT, child = zoom)
+28 -18
View File
@@ -91,12 +91,16 @@ COLOR_SCHEMES = {
DEFAULT_COLOR_SCHEME = "cyberpunk" # matches the station's own site theme
# How far in the album art zoom goes, relative to its fully-zoomed-out rest
# state (see _art_widget below for why scale never goes below 1.0).
# state (see _art_widget below for why scale never goes below 1.0). "strong"
# is capped at 1.45, not pushed further: with the centering Translate in
# _art_widget, empirically anything above ~1.46 starts revealing a real
# black border in the bottom-right corner (verified with the calibration
# tool) -- this is the safe ceiling, not an arbitrary choice.
ZOOM_LEVELS = {
"off": 1.0,
"subtle": 1.15,
"moderate": 1.4,
"strong": 1.7,
"strong": 1.45,
}
DEFAULT_ZOOM_LEVEL = "moderate"
@@ -224,36 +228,42 @@ def _art_widget(art_bytes, zoom_scale):
# 1.0". Using the same wrapper unconditionally guarantees the "off"
# state and the rest state of every zoom level line up exactly.
#
# animation.Scale's pivot is fixed at the top-left corner (confirmed
# empirically; an explicit `origin` param has no effect at all). That
# means zooming in crops progressively more off the bottom and right
# than the top and left -- a real cosmetic asymmetry. Pairing Scale
# with a Translate to re-center it was tried (and visually worked),
# but introduced a worse problem: combining Scale + Translate causes
# pixlet to periodically re-rasterize the whole image with a visible
# "pop" (confirmed with both WebP and GIF output, and independent of
# zoom amplitude -- so it's pixlet's animation engine, not this app's
# math). Scale *alone*, with no Translate at all, has none of that
# popping. Given the choice between a subtle, constant crop asymmetry
# and a periodic visible pop, the asymmetry is the lesser issue, so
# this uses Scale only.
# animation.Scale's pivot is the center of the widget by default (per
# pixlet's own docs) -- so zooming in via Scale alone is already close
# to centered, just slightly skewed toward the top-left as zoom
# increases. Pairing Scale with a Translate removes that residual skew.
#
# Root-caused the earlier "periodic pop" bug (found when this was first
# tried) to pixlet's own animation.Transformation source: a Translate's
# x/y are rounded to the nearest whole pixel every frame by default
# (`rounding = "round"`), while Scale is never rounded. Combined with a
# continuously-interpolating Scale, that rounding makes the Translate
# hold still for several frames and then jump a whole pixel at once --
# a real, confirmed pixlet behavior (see render/animation/translate.go),
# not a flaw in this app's math. `rounding = "none"` disables that
# snapping, letting Translate interpolate continuously alongside Scale
# -- confirmed via frame-by-frame pixel-delta analysis: the per-frame
# change is now even throughout the animation, instead of long still
# stretches punctuated by sudden full-frame jumps.
top_scale = max(zoom_scale, 1.0)
shift = (1.0 - top_scale) * (ART_SIZE / 2.0)
zoom = animation.Transformation(
child = art,
duration = ART_ZOOM_DURATION,
rounding = "none",
keyframes = [
animation.Keyframe(
percentage = 0.0,
transforms = [animation.Scale(1.0, 1.0)],
transforms = [animation.Scale(1.0, 1.0), animation.Translate(0, 0)],
),
animation.Keyframe(
percentage = 0.5,
transforms = [animation.Scale(top_scale, top_scale)],
transforms = [animation.Scale(top_scale, top_scale), animation.Translate(shift, shift)],
curve = "ease_in_out",
),
animation.Keyframe(
percentage = 1.0,
transforms = [animation.Scale(1.0, 1.0)],
transforms = [animation.Scale(1.0, 1.0), animation.Translate(0, 0)],
curve = "ease_in_out",
),
],
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.