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 attempts produced a real, on-device black border; see the fuller
history below. history below.
`animation.Scale`'s pivot defaults to the top-left corner (confirmed `animation.Scale`'s pivot defaults to the *center* of the widget per
empirically — an explicit `origin` parameter has no effect at all), so pixlet's own docs, so Scale alone is already close to centered — just
zooming in crops a bit more off the bottom/right than the top/left. A slightly skewed toward the top-left as the zoom amount increases.
centered version (pairing Scale with a computed `animation.Translate`) Paired with a computed `animation.Translate` to cancel out that residual
was built and visually worked, but combining Scale with Translate turned skew. Combining Scale with Translate at all was tried once before and
out to make pixlet periodically re-rasterize the whole image with a reverted after it produced a periodic rendering "pop" roughly every
visible "pop" every ~0.7-1s — confirmed with both WebP and GIF output, 0.7-1s — confirmed with both WebP and GIF output, independent of zoom
independent of zoom amplitude, so it's a property of pixlet's animation amplitude. Root-caused to pixlet's own source
engine, not this app's math. Scale *alone* has none of that popping. (`render/animation/translate.go`): a Translate's x/y are rounded to the
Given the choice between a subtle, constant crop asymmetry and a nearest whole pixel every frame by default, while Scale is never
periodic visible pop, this app accepts the asymmetry. (`dev/calibration_test.star` rounded, so a continuously-changing Scale combined with a rounded
has a working centered version if anyone wants to revisit this trade-off.) Translate holds still for several frames and then jumps a whole pixel
Verified payload stays well under the 460KB device limit at every zoom at once. Passing `rounding = "none"` on the `Transformation` disables
level, measured against the live station. 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 **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 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 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 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 calibration.png is a 360x360 grid: concentric 10%-wide color rings (magenta
outermost, then yellow, then cyan) plus four corner markers (red TL, green 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 Keep ART_SIZE/ART_SLOT/the zoom math below in sync with the real
_art_widget if that ever changes. _art_widget if that ever changes.
Note: a centered (Scale + Translate) version of this zoom was tried and History, if this ever needs revisiting: a Scale+Translate centered zoom
visually fixed the top-left crop bias below, but combining Scale with was tried, then reverted after it produced a periodic rendering "pop"
Translate causes pixlet to periodically re-rasterize the whole image with (confirmed with both WebP and GIF output, independent of zoom amplitude).
a visible "pop" -- confirmed with both WebP and GIF output, independent of Root cause, found in pixlet's own source
zoom amplitude, so it's pixlet's animation engine, not app-level math. This (render/animation/translate.go): a Translate's x/y are rounded to the
tool (and the real app) stick to Scale alone -- a real but minor crop-bias nearest whole pixel every frame by default (`rounding = "round"`), while
cosmetic issue, instead of the pop a centered zoom introduces. If you want Scale is never rounded -- so a continuously-interpolating Scale combined
to re-investigate centering, add animation.Translate back in here first with a rounded Translate holds still for several frames, then jumps a
and compare frame-to-frame pixel deltas (see git history around the whole pixel at once. Passing `rounding = "none"` on the Transformation
"Center the album art zoom" / "Revert jerky zoom fix" commits) before (see below) disables that snapping and fixed it -- confirmed via
touching the real app. 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") load("render.star", "render")
@@ -38,13 +42,15 @@ ART_ZOOM_DURATION = 200
def _art_widget(art_bytes, zoom_scale): def _art_widget(art_bytes, zoom_scale):
art = render.Image(src = art_bytes, width = ART_SIZE, height = ART_SIZE) art = render.Image(src = art_bytes, width = ART_SIZE, height = ART_SIZE)
top_scale = max(zoom_scale, 1.0) top_scale = max(zoom_scale, 1.0)
shift = (1.0 - top_scale) * (ART_SIZE / 2.0)
zoom = animation.Transformation( zoom = animation.Transformation(
child = art, child = art,
duration = ART_ZOOM_DURATION, duration = ART_ZOOM_DURATION,
rounding = "none",
keyframes = [ keyframes = [
animation.Keyframe(percentage = 0.0, transforms = [animation.Scale(1.0, 1.0)]), 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)], curve = "ease_in_out"), 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)], 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) 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 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 # 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 = { ZOOM_LEVELS = {
"off": 1.0, "off": 1.0,
"subtle": 1.15, "subtle": 1.15,
"moderate": 1.4, "moderate": 1.4,
"strong": 1.7, "strong": 1.45,
} }
DEFAULT_ZOOM_LEVEL = "moderate" 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" # 1.0". Using the same wrapper unconditionally guarantees the "off"
# state and the rest state of every zoom level line up exactly. # state and the rest state of every zoom level line up exactly.
# #
# animation.Scale's pivot is fixed at the top-left corner (confirmed # animation.Scale's pivot is the center of the widget by default (per
# empirically; an explicit `origin` param has no effect at all). That # pixlet's own docs) -- so zooming in via Scale alone is already close
# means zooming in crops progressively more off the bottom and right # to centered, just slightly skewed toward the top-left as zoom
# than the top and left -- a real cosmetic asymmetry. Pairing Scale # increases. Pairing Scale with a Translate removes that residual skew.
# with a Translate to re-center it was tried (and visually worked), #
# but introduced a worse problem: combining Scale + Translate causes # Root-caused the earlier "periodic pop" bug (found when this was first
# pixlet to periodically re-rasterize the whole image with a visible # tried) to pixlet's own animation.Transformation source: a Translate's
# "pop" (confirmed with both WebP and GIF output, and independent of # x/y are rounded to the nearest whole pixel every frame by default
# zoom amplitude -- so it's pixlet's animation engine, not this app's # (`rounding = "round"`), while Scale is never rounded. Combined with a
# math). Scale *alone*, with no Translate at all, has none of that # continuously-interpolating Scale, that rounding makes the Translate
# popping. Given the choice between a subtle, constant crop asymmetry # hold still for several frames and then jump a whole pixel at once --
# and a periodic visible pop, the asymmetry is the lesser issue, so # a real, confirmed pixlet behavior (see render/animation/translate.go),
# this uses Scale only. # 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) top_scale = max(zoom_scale, 1.0)
shift = (1.0 - top_scale) * (ART_SIZE / 2.0)
zoom = animation.Transformation( zoom = animation.Transformation(
child = art, child = art,
duration = ART_ZOOM_DURATION, duration = ART_ZOOM_DURATION,
rounding = "none",
keyframes = [ keyframes = [
animation.Keyframe( animation.Keyframe(
percentage = 0.0, percentage = 0.0,
transforms = [animation.Scale(1.0, 1.0)], transforms = [animation.Scale(1.0, 1.0), animation.Translate(0, 0)],
), ),
animation.Keyframe( animation.Keyframe(
percentage = 0.5, 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", curve = "ease_in_out",
), ),
animation.Keyframe( animation.Keyframe(
percentage = 1.0, 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", 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.