From 4a2b3caac83ba810a09c6b13ee60a8a7adba75ba Mon Sep 17 00:00:00 2001 From: jim kolpack Date: Fri, 2 Oct 2026 22:03:05 -0400 Subject: [PATCH] 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. --- README.md | 38 ++++++++++++++++++++------------ dev/calibration_test.star | 36 +++++++++++++++++------------- subwave_nowplaying.star | 46 ++++++++++++++++++++++++--------------- 3 files changed, 73 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index d0f8b8c..5acfad6 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/dev/calibration_test.star b/dev/calibration_test.star index bad06f4..1cb9a5c 100644 --- a/dev/calibration_test.star +++ b/dev/calibration_test.star @@ -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) diff --git a/subwave_nowplaying.star b/subwave_nowplaying.star index cdea548..147c75e 100644 --- a/subwave_nowplaying.star +++ b/subwave_nowplaying.star @@ -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", ), ],