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

+35 -23
View File
@@ -23,6 +23,9 @@ app's config when adding it to a device.
muted color for the time/temp line.
- **Album art zoom** — how far the album art's breathing zoom effect goes
in: Off, Subtle, Moderate (default), or Strong.
- **Zoom position** — where the zoom centers on: Center (default), or
Random (a handful of preset off-center spots, picked per-track so a
still-playing track doesn't jump between spots on every refresh).
## How it works
@@ -46,30 +49,39 @@ 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 *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
Where the zoom centers on is controlled via `animation.Origin`, which
works correctly and does real work here — contrary to an earlier,
mistaken belief in this app's own history that it "had no effect" (that
came from testing it set to its own default value and comparing the
result against itself, which is of course identical either way). The
"Random" zoom position picks from a handful of preset off-center spots
(`ZOOM_RANDOM_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 `NOWPLAYING_TTL` refresh. Those spots are
kept modest (0.3-0.7 of the image's own bounds, not the full 0.0-1.0
range) 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 safe.
An earlier version tried to fix a *different* problem — `animation.Scale`
zooming in asymmetrically toward one corner — by pairing Scale with a
*computed* `animation.Translate`, reasoning (wrongly) 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. That Translate, sized
assuming Scale had no self-centering at all, overcorrected and visibly
skewed the zoom toward the *opposite* corner instead. It also introduced
a periodic rendering "pop" (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).
nearest whole pixel every frame by default, while Scale never rounds,
so a continuously-changing Scale combined with a rounded Translate
holds still for several frames and then jumps a whole pixel at once.
Since `animation.Origin` already does directly and correctly what that
Translate was clumsily approximating, the Translate was removed
entirely rather than patched further (e.g. with `rounding = "none"`,
which does fix the popping but wasn't needed once Origin replaced it).
Verified payload stays well under the 460KB device limit at every zoom
level and position, measured against the live station.
**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
+24 -20
View File
@@ -11,24 +11,27 @@ TR, blue BL, white BR) and a center crosshair.
Usage:
pixlet render dev/calibration_test.star -o calib.gif
pixlet render dev/calibration_test.star zoom_scale=1.0 -o calib_rest.gif # rest only
pixlet render dev/calibration_test.star ox=0.3 oy=0.3 -o calib_offcenter.gif
Keep ART_SIZE/ART_SLOT/the zoom math below in sync with the real
_art_widget if that ever changes.
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.
was tried first and produced a periodic rendering "pop", root-caused to
pixlet's own source (render/animation/translate.go): Translate's x/y are
rounded to the nearest whole pixel every frame by default, while Scale
never rounds, so a continuously-interpolating Scale combined with a
rounded Translate holds still for several frames then jumps a whole pixel
at once. `rounding = "none"` fixed that. But the Translate's *magnitude*
was also wrong: it assumed Scale pivots at the top-left corner by
default, when pixlet's docs say the default `origin` is already the
center (0.5, 0.5) -- so the "centering" Translate was overcorrecting,
visibly skewing the zoom toward the bottom-right instead. Replaced with
`origin` directly (which does, correctly, what that Translate was
clumsily approximating), confirmed via this calibration tool with
genuinely different (non-default) origin values -- they do change the
output, despite an earlier, flawed test (origin set to its own default
and compared against itself) suggesting `origin` had no effect at all.
"""
load("render.star", "render")
@@ -39,25 +42,26 @@ ART_SIZE = 36
ART_SLOT = 32
ART_ZOOM_DURATION = 200
def _art_widget(art_bytes, zoom_scale):
def _art_widget(art_bytes, zoom_scale, origin_x, origin_y):
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",
origin = animation.Origin(origin_x, origin_y),
keyframes = [
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"),
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"),
],
)
return render.Box(width = ART_SLOT, height = ART_SLOT, child = zoom)
def main(config):
zoom_scale = float(config.get("zoom_scale", "1.4"))
origin_x = float(config.get("ox", "0.5"))
origin_y = float(config.get("oy", "0.5"))
return render.Root(child = render.Row(children = [
_art_widget(CALIB.readall(), zoom_scale),
_art_widget(CALIB.readall(), zoom_scale, origin_x, origin_y),
render.Box(width = 32, height = 32, color = "#111111"),
]))
+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.