Fix black border in album art zoom by only ever scaling up
The corner-reveal zoom scaled down from 1.0 toward ~0.71 (via Translate to shift toward a corner), which showed up on a real device as a black border cutting into the art. Empirical testing against the actual Row layout at native canvas size showed the outer Box auto-fits an oversized child at Scale(1.0, 1.0), so scaling down from there shrinks the already- fitted image further and exposes background outside its bounds. Scaling up only ever crops further into the image, which is always safe. This drops Translate and the per-track corner selection (animation.Scale pivots at the top-left corner by default with no way to change it -- an explicit origin parameter was tried and had no effect), so the effect now always zooms into the top-left corner instead of a track-dependent one.
This commit is contained in:
1 parent
83b817c3b2
commit
cda256d1c2
2 files changed
+75
-79
No files matched your search
@@ -28,38 +28,45 @@ app's config when adding it to a device.
|
|||||||
instead of stale track info.
|
instead of stale track info.
|
||||||
- If a track has no `subsonic_id`, or the cover fetch fails, falls back to a
|
- If a track has no `subsonic_id`, or the cover fetch fails, falls back to a
|
||||||
full-width layout with no art rather than a broken image.
|
full-width layout with no art rather than a broken image.
|
||||||
- Album art gets a slow "Ken Burns" breathing effect: eases from one corner
|
- Album art gets a slow "Ken Burns" breathing effect: eases from the full
|
||||||
of the image out to the full frame, then back into that same corner, on a
|
frame in to a zoomed-in crop of the top-left corner, then back out, on a
|
||||||
continuous ~60s round-trip loop (not a one-shot reveal — see below).
|
continuous ~60s round-trip loop (not a one-shot reveal — see below).
|
||||||
Rendered oversized (45x45 vs. the 32x32 display slot) and scaled between
|
Rendered oversized (45x45 vs. the 32x32 display slot) since
|
||||||
1.0 and ~0.71, since `animation.Scale` above 1.0 has no visible effect on
|
`animation.Scale` has no visible effect on a widget whose declared size
|
||||||
a widget whose declared size already matches its container. Which corner
|
already matches its container, and scaled between 1.0 and 1.4 — only ever
|
||||||
it uses is derived from the track's `subsonic_id` (not true randomness) so
|
scaling *up*, never down. That's a fix for a real on-device bug: an
|
||||||
a still-playing track re-rendered on the `NOWPLAYING_TTL` cache doesn't
|
earlier version zoomed *out* from a corner (scaling down from 1.0 toward
|
||||||
jarringly jump corners — only a genuinely new track does. Verified this
|
~0.71, reached via `animation.Translate`), which showed up on a real
|
||||||
doesn't blow the payload budget: counterintuitively, a *slower* zoom
|
device as a black border cutting into the art. Empirical testing (against
|
||||||
compresses *smaller*, since more adjacent ticks become pixel-identical at
|
the actual Row layout, at native canvas size) showed the outer `Box`
|
||||||
this resolution and WebP dedupes them — measured 97-264KB depending on the
|
auto-fits an oversized child to its own size at `Scale(1.0, 1.0)` — so
|
||||||
art's own visual complexity, all well under the 460KB device limit.
|
scaling *down* from there shrinks the already-fitted image further,
|
||||||
|
exposing background outside its bounds. Scaling *up* only ever crops
|
||||||
|
further into the image, which is always safe. `animation.Scale`'s pivot
|
||||||
|
defaults to the top-left corner (confirmed empirically — an explicit
|
||||||
|
`origin` parameter was tried and had no effect at all), so there's no
|
||||||
|
reliable way to zoom into any corner but that one; the effect always
|
||||||
|
zooms into the top-left. Verified this doesn't blow the payload budget:
|
||||||
|
measured ~280KB for a full render against the live station, comfortably
|
||||||
|
under the 460KB device limit.
|
||||||
|
|
||||||
**Why a loop, not a one-shot reveal:** a one-way "start in corner, end on
|
**Why a loop, not a one-shot reveal:** a one-way version looked jumpy in
|
||||||
full frame, then hold" version looked jumpy in practice — since the app
|
practice — since the app re-renders on `NOWPLAYING_TTL`, each fresh
|
||||||
re-renders on `NOWPLAYING_TTL`, each fresh render restarts its own
|
render restarts its own animation from tick 0, so whatever the *previous*
|
||||||
animation from tick 0, so whatever the *previous* pushed image happened to
|
pushed image happened to be showing gets abruptly replaced by a fresh
|
||||||
be showing gets abruptly replaced by a fresh "zoomed into a corner" frame.
|
zoomed-in frame. Looping back-and-forth doesn't eliminate that (a
|
||||||
Looping back-and-forth doesn't eliminate that (a server-side re-render
|
server-side re-render always restarts at tick 0 regardless of app logic),
|
||||||
always restarts at tick 0 regardless of app logic), but ending exactly
|
but ending exactly where it started means the animation also loops
|
||||||
where it started means the animation also loops seamlessly *on-device*
|
seamlessly *on-device* between refreshes, which reads as far less jarring
|
||||||
between refreshes, which reads as far less jarring than a one-way reveal
|
than a one-way reveal that snaps back. Considered using genuinely random
|
||||||
that snaps back. Considered using genuinely random per-frame content (like
|
per-frame content (like `ormakers-tronbyt`'s LoRa segment, which pre-crops
|
||||||
`ormakers-tronbyt`'s LoRa segment, which pre-crops 60 frames directly from
|
60 frames directly from a high-resolution *static* source photo via
|
||||||
a high-resolution *static* source photo via offline Pillow processing) for
|
offline Pillow processing) for a sharper zoom, but that technique
|
||||||
a sharper zoom, but that technique fundamentally doesn't transfer here:
|
fundamentally doesn't transfer here: album art is fetched live per-track
|
||||||
album art is fetched live per-track at render time, and pixlet's Starlark
|
at render time, and pixlet's Starlark sandbox has no image-cropping/
|
||||||
sandbox has no image-cropping/pixel-analysis capability at all (confirmed
|
pixel-analysis capability at all (confirmed by checking its full module
|
||||||
by checking its full module list) — so there's no way to pre-bake or
|
list) — so there's no way to pre-bake or crop-at-render-time from a
|
||||||
crop-at-render-time from a source we don't have until the request hits
|
source we don't have until the request hits the live station.
|
||||||
the live station.
|
|
||||||
- Shows the current show name (`context.activeShow.name`) and host name
|
- Shows the current show name (`context.activeShow.name`) and host name
|
||||||
(`dj.name`) below the artist, separated by a thin divider line, and the
|
(`dj.name`) below the artist, separated by a thin divider line, and the
|
||||||
local time/temperature (`context.clock`/`context.weather`) below that —
|
local time/temperature (`context.clock`/`context.weather`) below that —
|
||||||
|
|||||||
+38
-49
@@ -72,7 +72,7 @@ def main(config):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if art:
|
if art:
|
||||||
content = render.Row(children = [_art_widget(art, now["subsonic_id"]), text_column])
|
content = render.Row(children = [_art_widget(art), text_column])
|
||||||
else:
|
else:
|
||||||
content = render.Column(
|
content = render.Column(
|
||||||
main_align = "center",
|
main_align = "center",
|
||||||
@@ -86,72 +86,61 @@ def main(config):
|
|||||||
|
|
||||||
return render.Root(child = content)
|
return render.Root(child = content)
|
||||||
|
|
||||||
# Album art gets a slow "Ken Burns" breathing effect: eases from one corner
|
# Album art gets a slow "Ken Burns" breathing effect: eases from the full
|
||||||
# out to the full frame, then back into that same corner, on a continuous
|
# frame in to a zoomed-in crop of the top-left corner, then back out, on a
|
||||||
# loop -- not a one-shot reveal. A one-shot version (start in a corner, end
|
# continuous loop -- not a one-shot reveal. A one-shot version (start
|
||||||
# on the full frame, then hold) looked jumpy: since the app re-renders on
|
# zoomed in, end on the full frame, then hold) looked jumpy: since the app
|
||||||
# NOWPLAYING_TTL and each fresh render restarts its own animation from tick
|
# re-renders on NOWPLAYING_TTL and each fresh render restarts its own
|
||||||
# 0, whatever the *previous* pushed image happened to be showing at that
|
# animation from tick 0, whatever the *previous* pushed image happened to
|
||||||
# moment gets abruptly replaced by a fresh "zoomed into a corner" frame. The
|
# be showing at that moment gets abruptly replaced by a fresh zoomed-in
|
||||||
# back-and-forth loop doesn't eliminate that (a server-side re-render always
|
# frame. The back-and-forth loop doesn't eliminate that (a server-side
|
||||||
# restarts at tick 0 regardless), but ending exactly where it started means
|
# re-render always restarts at tick 0 regardless), but ending exactly where
|
||||||
# the animation also loops seamlessly on-device between refreshes, which in
|
# it started means the animation also loops seamlessly on-device between
|
||||||
# practice reads as far less jarring than a one-way reveal that snaps back.
|
# refreshes, which in practice reads as far less jarring than a one-way
|
||||||
|
# reveal that snaps back.
|
||||||
#
|
#
|
||||||
# Art is rendered oversized (45x45, vs. the 32x32 display slot) since
|
# Art is rendered oversized (45x45, vs. the 32x32 display slot) since
|
||||||
# animation.Scale above 1.0 has no visible effect on a widget whose declared
|
# animation.Scale has no visible effect on a widget whose declared size
|
||||||
# size already matches its container (confirmed elsewhere this session) --
|
# already matches its container. Only ever scaling *up* from 1.0 (never
|
||||||
# starting at native 1.0 scale of the *oversized* image and easing down to
|
# below) is deliberate, not cosmetic: this app originally scaled down to
|
||||||
# ~32/45 achieves "zoom out to reveal everything" while only ever using
|
# ~32/45 to "zoom out" from a corner reached via animation.Translate, which
|
||||||
# Scale <= 1.0. Combining Scale + Translate in one Transformation, and
|
# produced a real, on-device black border. Empirical testing (rendering the
|
||||||
# combining that with the app's 4 already-scrolling Marquee lines, was
|
# actual Row layout at native canvas size, no forced output dimensions)
|
||||||
# verified not to trigger any *additional* glitching beyond the already-
|
# showed the outer Box auto-fits an oversized child to its own size at
|
||||||
# accepted minor Marquee quirk (see README). Duration 1200 ticks x the
|
# animation.Scale(1.0, 1.0) -- so scaling *down* from there shrinks the
|
||||||
# default 50ms/tick = 60s round-trip (30s out, 30s back); verified
|
# already-fitted image further, exposing background outside its bounds
|
||||||
# empirically that a slower zoom compresses *smaller*, not larger, since more
|
# (the black border). Scaling *up* only ever crops further into the image,
|
||||||
# adjacent ticks become pixel-identical at this resolution -- ~97-264KB
|
# which is always safe. animation.Scale's pivot defaults to the top-left
|
||||||
# depending on the art's own complexity, well under the 460KB device limit.
|
# corner (confirmed empirically; an explicit `origin` param had no effect
|
||||||
|
# at all, so isn't used here) -- there's no reliable way to zoom into any
|
||||||
|
# other corner, so this always zooms into the top-left. Duration 1200
|
||||||
|
# ticks x the default 50ms/tick = 60s round-trip (30s in, 30s out);
|
||||||
|
# verified empirically that a slower zoom compresses *smaller*, not
|
||||||
|
# larger, since more adjacent ticks become pixel-identical at this
|
||||||
|
# resolution -- well under the 460KB device limit.
|
||||||
ART_SIZE = 45
|
ART_SIZE = 45
|
||||||
ART_SLOT = 32
|
ART_SLOT = 32
|
||||||
ART_MAX_OFFSET = (ART_SIZE - ART_SLOT) / 2.0 # 6.5px: centered -> true corner
|
ART_ZOOM_SCALE = 1.4 # how far in to zoom on the top-left corner
|
||||||
ART_END_SCALE = float(ART_SLOT) / float(ART_SIZE) # ~0.71: full art fits in the slot
|
|
||||||
ART_ZOOM_DURATION = 1200 # 60s at the default 50ms/tick
|
ART_ZOOM_DURATION = 1200 # 60s at the default 50ms/tick
|
||||||
|
|
||||||
CORNER_OFFSETS = [
|
def _art_widget(art_bytes):
|
||||||
(-ART_MAX_OFFSET, -ART_MAX_OFFSET),
|
art = render.Image(src = art_bytes, width = ART_SIZE, height = ART_SIZE)
|
||||||
(ART_MAX_OFFSET, -ART_MAX_OFFSET),
|
|
||||||
(-ART_MAX_OFFSET, ART_MAX_OFFSET),
|
|
||||||
(ART_MAX_OFFSET, ART_MAX_OFFSET),
|
|
||||||
]
|
|
||||||
|
|
||||||
def _corner_for_track(subsonic_id):
|
|
||||||
# Pseudo-random but stable per track, so re-rendering the same
|
|
||||||
# still-playing track (every NOWPLAYING_TTL seconds) doesn't jarringly
|
|
||||||
# jump to a different corner -- only a genuinely new track does.
|
|
||||||
total = 0
|
|
||||||
for ch in subsonic_id.elems():
|
|
||||||
total += ord(ch)
|
|
||||||
return CORNER_OFFSETS[total % len(CORNER_OFFSETS)]
|
|
||||||
|
|
||||||
def _art_widget(art_bytes, subsonic_id):
|
|
||||||
corner_x, corner_y = _corner_for_track(subsonic_id)
|
|
||||||
big_art = render.Image(src = art_bytes, width = ART_SIZE, height = ART_SIZE)
|
|
||||||
zoom = animation.Transformation(
|
zoom = animation.Transformation(
|
||||||
child = big_art,
|
child = art,
|
||||||
duration = ART_ZOOM_DURATION,
|
duration = ART_ZOOM_DURATION,
|
||||||
keyframes = [
|
keyframes = [
|
||||||
animation.Keyframe(
|
animation.Keyframe(
|
||||||
percentage = 0.0,
|
percentage = 0.0,
|
||||||
transforms = [animation.Scale(1.0, 1.0), animation.Translate(corner_x, corner_y)],
|
transforms = [animation.Scale(ART_ZOOM_SCALE, ART_ZOOM_SCALE)],
|
||||||
),
|
),
|
||||||
animation.Keyframe(
|
animation.Keyframe(
|
||||||
percentage = 0.5,
|
percentage = 0.5,
|
||||||
transforms = [animation.Scale(ART_END_SCALE, ART_END_SCALE), animation.Translate(0, 0)],
|
transforms = [animation.Scale(1.0, 1.0)],
|
||||||
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(corner_x, corner_y)],
|
transforms = [animation.Scale(ART_ZOOM_SCALE, ART_ZOOM_SCALE)],
|
||||||
curve = "ease_in_out",
|
curve = "ease_in_out",
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||