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. muted color for the time/temp line.
- **Album art zoom** — how far the album art's breathing zoom effect goes - **Album art zoom** — how far the album art's breathing zoom effect goes
in: Off, Subtle, Moderate (default), or Strong. 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 ## 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 attempts produced a real, on-device black border; see the fuller
history below. history below.
`animation.Scale`'s pivot defaults to the *center* of the widget per Where the zoom centers on is controlled via `animation.Origin`, which
pixlet's own docs, so Scale alone is already close to centered — just works correctly and does real work here — contrary to an earlier,
slightly skewed toward the top-left as the zoom amount increases. mistaken belief in this app's own history that it "had no effect" (that
Paired with a computed `animation.Translate` to cancel out that residual came from testing it set to its own default value and comparing the
skew. Combining Scale with Translate at all was tried once before and result against itself, which is of course identical either way). The
reverted after it produced a periodic rendering "pop" roughly every "Random" zoom position picks from a handful of preset off-center spots
0.7-1s — confirmed with both WebP and GIF output, independent of zoom (`ZOOM_RANDOM_SPOTS`), chosen per-track from `subsonic_id` the same way
amplitude. Root-caused to pixlet's own source 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 (`render/animation/translate.go`): a Translate's x/y are rounded to the
nearest whole pixel every frame by default, while Scale is never nearest whole pixel every frame by default, while Scale never rounds,
rounded, so a continuously-changing Scale combined with a rounded so a continuously-changing Scale combined with a rounded Translate
Translate holds still for several frames and then jumps a whole pixel holds still for several frames and then jumps a whole pixel at once.
at once. Passing `rounding = "none"` on the `Transformation` disables Since `animation.Origin` already does directly and correctly what that
that snapping and fixes it — confirmed via frame-by-frame pixel-delta Translate was clumsily approximating, the Translate was removed
analysis (the amount of change is now even from frame to frame, instead entirely rather than patched further (e.g. with `rounding = "none"`,
of long still stretches punctuated by sudden jumps). "strong" is capped which does fix the popping but wasn't needed once Origin replaced it).
at 1.45 (not pushed further): above ~1.46 the centered crop starts Verified payload stays well under the 460KB device limit at every zoom
revealing a real black border in the bottom-right corner, verified with level and position, measured against the live station.
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
+24 -20
View File
@@ -11,24 +11,27 @@ TR, blue BL, white BR) and a center crosshair.
Usage: Usage:
pixlet render dev/calibration_test.star -o calib.gif 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 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 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.
History, if this ever needs revisiting: a Scale+Translate centered zoom History, if this ever needs revisiting: a Scale+Translate centered zoom
was tried, then reverted after it produced a periodic rendering "pop" was tried first and produced a periodic rendering "pop", root-caused to
(confirmed with both WebP and GIF output, independent of zoom amplitude). pixlet's own source (render/animation/translate.go): Translate's x/y are
Root cause, found in pixlet's own source rounded to the nearest whole pixel every frame by default, while Scale
(render/animation/translate.go): a Translate's x/y are rounded to the never rounds, so a continuously-interpolating Scale combined with a
nearest whole pixel every frame by default (`rounding = "round"`), while rounded Translate holds still for several frames then jumps a whole pixel
Scale is never rounded -- so a continuously-interpolating Scale combined at once. `rounding = "none"` fixed that. But the Translate's *magnitude*
with a rounded Translate holds still for several frames, then jumps a was also wrong: it assumed Scale pivots at the top-left corner by
whole pixel at once. Passing `rounding = "none"` on the Transformation default, when pixlet's docs say the default `origin` is already the
(see below) disables that snapping and fixed it -- confirmed via center (0.5, 0.5) -- so the "centering" Translate was overcorrecting,
frame-by-frame pixel-delta analysis (even per-frame change throughout, visibly skewing the zoom toward the bottom-right instead. Replaced with
instead of long still stretches punctuated by sudden jumps). If a future `origin` directly (which does, correctly, what that Translate was
change reintroduces jerkiness, check whether `rounding = "none"` is still clumsily approximating), confirmed via this calibration tool with
present before suspecting the shift formula. 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") load("render.star", "render")
@@ -39,25 +42,26 @@ ART_SIZE = 36
ART_SLOT = 32 ART_SLOT = 32
ART_ZOOM_DURATION = 200 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) 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", origin = animation.Origin(origin_x, origin_y),
keyframes = [ keyframes = [
animation.Keyframe(percentage = 0.0, transforms = [animation.Scale(1.0, 1.0), animation.Translate(0, 0)]), animation.Keyframe(percentage = 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)], curve = "ease_in_out"), 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), animation.Translate(0, 0)], 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) return render.Box(width = ART_SLOT, height = ART_SLOT, child = zoom)
def main(config): def main(config):
zoom_scale = float(config.get("zoom_scale", "1.4")) 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 = [ 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"), 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 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). "strong" # state (see _art_widget below for why scale never goes below 1.0).
# 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.45, "strong": 1.7,
} }
DEFAULT_ZOOM_LEVEL = "moderate" 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): def main(config):
station_url = config.get("station_url", DEFAULT_STATION_URL) station_url = config.get("station_url", DEFAULT_STATION_URL)
if 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]) 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_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: if not station_url:
return render.Root(child = _with_background(_message_screen(["Set station URL", "in app settings"], colors), colors)) 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: 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: else:
content = render.Column( content = render.Column(
main_align = "center", main_align = "center",
@@ -215,7 +242,7 @@ ART_SIZE = 36
ART_SLOT = 32 ART_SLOT = 32
ART_ZOOM_DURATION = 200 # 10s round-trip (5s in, 5s out) at the default 50ms/tick 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) art = render.Image(src = art_bytes, width = ART_SIZE, height = ART_SIZE)
# Always route through animation.Transformation, even when zoom is off # 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" # 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 the center of the widget by default (per # `origin` picks where the zoom centers on, which also gives a free
# pixlet's own docs) -- so zooming in via Scale alone is already close # "zoom to a different spot each time" effect (ZOOM_SPOTS above) by
# to centered, just slightly skewed toward the top-left as zoom # just varying it per track. An earlier version tried to recenter the
# increases. Pairing Scale with a Translate removes that residual skew. # zoom by pairing Scale with a *computed* Translate, on the mistaken
# # assumption that Scale's default pivot is the top-left corner -- it
# Root-caused the earlier "periodic pop" bug (found when this was first # isn't; pixlet's own docs say the default origin is already the
# tried) to pixlet's own animation.Transformation source: a Translate's # center (0.5, 0.5). That Translate was sized assuming Scale had no
# x/y are rounded to the nearest whole pixel every frame by default # self-centering at all, so it overcorrected, visibly skewing the zoom
# (`rounding = "round"`), while Scale is never rounded. Combined with a # toward the bottom-right -- the opposite of the bug it was meant to
# continuously-interpolating Scale, that rounding makes the Translate # fix. Since `origin` already does directly and correctly what that
# hold still for several frames and then jump a whole pixel at once -- # Translate was clumsily approximating, this uses `origin` alone.
# a real, confirmed pixlet behavior (see render/animation/translate.go), origin_x, origin_y = _zoom_spot_for_track(zoom_position, subsonic_id)
# 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", origin = animation.Origin(origin_x, origin_y),
keyframes = [ keyframes = [
animation.Keyframe( animation.Keyframe(
percentage = 0.0, percentage = 0.0,
transforms = [animation.Scale(1.0, 1.0), animation.Translate(0, 0)], transforms = [animation.Scale(1.0, 1.0)],
), ),
animation.Keyframe( animation.Keyframe(
percentage = 0.5, 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", curve = "ease_in_out",
), ),
animation.Keyframe( animation.Keyframe(
percentage = 1.0, 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", curve = "ease_in_out",
), ),
], ],
@@ -405,5 +426,16 @@ def get_schema():
schema.Option(display = "Strong", value = "strong"), 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.