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