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
@@ -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
@@ -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
@@ -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"),
|
||||||
|
],
|
||||||
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||