Replace Translate-based centering with animation.Origin; add zoom position config

The previous "fix" for Scale's crop bias paired Scale with a computed
Translate, on the mistaken assumption that Scale's default pivot is the
top-left corner. It isn't: pixlet's docs say the default origin is
already the center (0.5, 0.5). That Translate was sized assuming Scale
had no self-centering at all, so it overcorrected, visibly skewing the
zoom toward the bottom-right -- the opposite of the bug it meant to fix.

animation.Origin does directly and correctly what that Translate was
clumsily approximating, so the Translate (and its rounding="none"
workaround) is removed entirely in favor of Origin. This also means
"strong" no longer needs the 1.45 safety cap -- restored to 1.7, since
the black-border risk was specific to the Translate formula, not Scale +
Origin (verified via the calibration tool at several off-center origins).

Added a "Zoom position" schema option: Center (default) or Random. Random
picks from a handful of preset off-center spots, chosen per-track from
subsonic_id the same way the old corner-selection logic worked, so a
still-playing track doesn't jump between spots on every refresh.

Verified: no black border, no popping (confirmed via frame-delta
analysis -- smooth throughout, no Translate means rounding is moot), and
payload well under the 460KB limit at every zoom level/position
combination, against the live station.
This commit is contained in:
poprhythm committed 2026-10-02 22:12:25 -04:00
1 parent 4a2b3caac8
commit d8add24c5e
3 files changed
+121 -73

No files matched your search

+62 -30
View File
@@ -91,19 +91,45 @@ 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). "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.
# state (see _art_widget below for why scale never goes below 1.0).
ZOOM_LEVELS = {
"off": 1.0,
"subtle": 1.15,
"moderate": 1.4,
"strong": 1.45,
"strong": 1.7,
}
DEFAULT_ZOOM_LEVEL = "moderate"
# Where the zoom centers on, as animation.Origin fractions of the image's
# own 36x36 bounds. Kept modest (0.3-0.7, not full 0.0-1.0) and verified
# safe (no black border, even at the strongest 1.7x zoom) via the
# calibration tool -- more extreme corners weren't tested and may not be.
# Picked per-track (stable via subsonic_id, like the old corner-selection
# logic) rather than per-render, so a still-playing track doesn't jump
# between spots on every NOWPLAYING_TTL refresh. Only used when the
# "Zoom position" schema field is set to "random" -- "center" always zooms
# on the image's own center, exposed as a config choice since some covers
# (e.g. ones with a centered logo or face) read better with a predictable
# center zoom than a per-track random one.
ZOOM_CENTER_SPOT = (0.5, 0.5)
ZOOM_RANDOM_SPOTS = [
(0.5, 0.5),
(0.3, 0.3),
(0.7, 0.7),
(0.3, 0.7),
(0.7, 0.3),
]
DEFAULT_ZOOM_POSITION = "center"
def _zoom_spot_for_track(zoom_position, subsonic_id):
if zoom_position != "random":
return ZOOM_CENTER_SPOT
total = 0
for ch in subsonic_id.elems():
total += ord(ch)
return ZOOM_RANDOM_SPOTS[total % len(ZOOM_RANDOM_SPOTS)]
def main(config):
station_url = config.get("station_url", DEFAULT_STATION_URL)
if station_url:
@@ -111,6 +137,7 @@ def main(config):
colors = COLOR_SCHEMES.get(config.get("color_scheme", DEFAULT_COLOR_SCHEME), COLOR_SCHEMES[DEFAULT_COLOR_SCHEME])
zoom_scale = ZOOM_LEVELS.get(config.get("zoom_level", DEFAULT_ZOOM_LEVEL), ZOOM_LEVELS[DEFAULT_ZOOM_LEVEL])
zoom_position = config.get("zoom_position", DEFAULT_ZOOM_POSITION)
if not station_url:
return render.Root(child = _with_background(_message_screen(["Set station URL", "in app settings"], colors), colors))
@@ -157,7 +184,7 @@ def main(config):
)
if art:
content = render.Row(children = [_art_widget(art, zoom_scale), text_column])
content = render.Row(children = [_art_widget(art, zoom_scale, zoom_position, now["subsonic_id"]), text_column])
else:
content = render.Column(
main_align = "center",
@@ -215,7 +242,7 @@ ART_SIZE = 36
ART_SLOT = 32
ART_ZOOM_DURATION = 200 # 10s round-trip (5s in, 5s out) at the default 50ms/tick
def _art_widget(art_bytes, zoom_scale):
def _art_widget(art_bytes, zoom_scale, zoom_position, subsonic_id):
art = render.Image(src = art_bytes, width = ART_SIZE, height = ART_SIZE)
# Always route through animation.Transformation, even when zoom is off
@@ -228,42 +255,36 @@ 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 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.
# `origin` picks where the zoom centers on, which also gives a free
# "zoom to a different spot each time" effect (ZOOM_SPOTS above) by
# just varying it per track. An earlier version tried to recenter the
# zoom by pairing Scale with a *computed* Translate, on the mistaken
# assumption that Scale's default pivot is the top-left corner -- it
# isn't; pixlet's own docs say the default origin is already the
# center (0.5, 0.5). That Translate was sized assuming Scale had no
# self-centering at all, so it overcorrected, visibly skewing the zoom
# toward the bottom-right -- the opposite of the bug it was meant to
# fix. Since `origin` already does directly and correctly what that
# Translate was clumsily approximating, this uses `origin` alone.
origin_x, origin_y = _zoom_spot_for_track(zoom_position, subsonic_id)
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",
origin = animation.Origin(origin_x, origin_y),
keyframes = [
animation.Keyframe(
percentage = 0.0,
transforms = [animation.Scale(1.0, 1.0), animation.Translate(0, 0)],
transforms = [animation.Scale(1.0, 1.0)],
),
animation.Keyframe(
percentage = 0.5,
transforms = [animation.Scale(top_scale, top_scale), animation.Translate(shift, shift)],
transforms = [animation.Scale(top_scale, top_scale)],
curve = "ease_in_out",
),
animation.Keyframe(
percentage = 1.0,
transforms = [animation.Scale(1.0, 1.0), animation.Translate(0, 0)],
transforms = [animation.Scale(1.0, 1.0)],
curve = "ease_in_out",
),
],
@@ -405,5 +426,16 @@ def get_schema():
schema.Option(display = "Strong", value = "strong"),
],
),
schema.Dropdown(
id = "zoom_position",
name = "Zoom position",
desc = "Where the album art zoom centers on.",
icon = "crosshairs",
default = DEFAULT_ZOOM_POSITION,
options = [
schema.Option(display = "Center", value = "center"),
schema.Option(display = "Random", value = "random"),
],
),
],
)
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.