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:
1 parent
4a2b3caac8
commit
d8add24c5e
3 files changed
+121
-73
No files matched your search
+62
-30
@@ -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"),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||