diff --git a/README.md b/README.md index b71db3a..d0f8b8c 100644 --- a/README.md +++ b/README.md @@ -34,45 +34,52 @@ 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 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) 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. +- Album art gets a slow "Ken Burns" breathing effect: starts and ends on + the full frame, easing in to a zoomed-in crop at the midpoint, on a + continuous ~10s round-trip loop (not a one-shot reveal — see below). How + far it zooms in is configurable (Off/Subtle/Moderate/Strong — see + Configuration). Rendered oversized (36x36 vs. the 32x32 display slot, + just enough headroom for `animation.Scale` to have a visible effect) + and scaled *up only*, from 1.0 to the configured zoom amount, never + below 1.0 — scaling down was tried (to make the rest state reveal the + literal whole image, or to re-center an off-center crop) and both + attempts produced a real, on-device black border; see the fuller + history below. - **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. + `animation.Scale`'s pivot defaults to the top-left corner (confirmed + empirically — an explicit `origin` parameter has no effect at all), so + zooming in crops a bit more off the bottom/right than the top/left. A + centered version (pairing Scale with a computed `animation.Translate`) + was built and visually worked, but combining Scale with Translate turned + out to make pixlet periodically re-rasterize the whole image with a + visible "pop" every ~0.7-1s — confirmed with both WebP and GIF output, + independent of zoom amplitude, so it's a property of pixlet's animation + engine, not this app's math. Scale *alone* has none of that popping. + Given the choice between a subtle, constant crop asymmetry and a + periodic visible pop, this app accepts the asymmetry. (`dev/calibration_test.star` + has a working centered version if anyone wants to revisit this trade-off.) + Verified payload stays well under the 460KB device limit at every zoom + level, measured against the live station. + + **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 + `NOWPLAYING_TTL` (and the server appears to refresh pushed apps well + under a minute regardless), a fresh render restarts its own animation + from tick 0, so whatever the *previous* pushed image happened to be + showing gets abruptly replaced. A loop that starts and ends in the same + state doesn't eliminate that restart, but makes it invisible — and + keeping the round-trip short (~10s) makes it far more likely a full + cycle plays before the next refresh cuts it off; a 60s version tried + earlier only ever showed its outbound half. 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/dev/calibration_test.star b/dev/calibration_test.star index e4c657d..bad06f4 100644 --- a/dev/calibration_test.star +++ b/dev/calibration_test.star @@ -1,15 +1,11 @@ """ Dev tool, not part of the deployed app: renders dev/calibration.png through the exact same zoom logic as _art_widget in subwave_nowplaying.star, so you -can visually check how much margin gets cropped at rest and at full zoom, -and whether the crop is even on all sides. +can visually check how much margin gets cropped at rest and at full zoom. calibration.png is a 360x360 grid: concentric 10%-wide color rings (magenta outermost, then yellow, then cyan) plus four corner markers (red TL, green -TR, blue BL, white BR) and a center crosshair. At rest, you should see a -thin, EVEN sliver of magenta on all four edges. As it zooms in, each corner -marker should get clipped by the same amount -- if one side/corner loses -noticeably more than the others, the zoom isn't centered. +TR, blue BL, white BR) and a center crosshair. Usage: pixlet render dev/calibration_test.star -o calib.gif @@ -17,6 +13,18 @@ Usage: Keep ART_SIZE/ART_SLOT/the zoom math below in sync with the real _art_widget if that ever changes. + +Note: a centered (Scale + Translate) version of this zoom was tried and +visually fixed the top-left crop bias below, but combining Scale with +Translate causes pixlet to periodically re-rasterize the whole image with +a visible "pop" -- confirmed with both WebP and GIF output, independent of +zoom amplitude, so it's pixlet's animation engine, not app-level math. This +tool (and the real app) stick to Scale alone -- a real but minor crop-bias +cosmetic issue, instead of the pop a centered zoom introduces. If you want +to re-investigate centering, add animation.Translate back in here first +and compare frame-to-frame pixel deltas (see git history around the +"Center the album art zoom" / "Revert jerky zoom fix" commits) before +touching the real app. """ load("render.star", "render") @@ -30,14 +38,13 @@ ART_ZOOM_DURATION = 200 def _art_widget(art_bytes, zoom_scale): art = render.Image(src = art_bytes, width = ART_SIZE, height = ART_SIZE) 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, keyframes = [ - animation.Keyframe(percentage = 0.0, transforms = [animation.Scale(1.0, 1.0), animation.Translate(0, 0)]), - animation.Keyframe(percentage = 0.5, transforms = [animation.Scale(top_scale, top_scale), animation.Translate(shift, shift)], 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 = 0.0, transforms = [animation.Scale(1.0, 1.0)]), + 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)], curve = "ease_in_out"), ], ) return render.Box(width = ART_SLOT, height = ART_SLOT, child = zoom) diff --git a/subwave_nowplaying.star b/subwave_nowplaying.star index 03c91cd..cdea548 100644 --- a/subwave_nowplaying.star +++ b/subwave_nowplaying.star @@ -91,16 +91,12 @@ 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" @@ -229,43 +225,35 @@ def _art_widget(art_bytes, zoom_scale): # state and the rest state of every zoom level line up exactly. # # animation.Scale's pivot is fixed at the top-left corner (confirmed - # empirically; an explicit `origin` param has no effect at all). Left - # alone, that means zooming in crops progressively *more* off the - # bottom and right than the top and left -- most of the animation's - # duration is spent between keyframes, not sitting at the symmetric - # rest state, so this read as a persistent bottom/right crop. Pairing - # Scale with a Translate re-centers the zoom so it crops evenly from - # all sides instead -- verified against a calibration grid image. - # - # shift = (1-s)*(ART_SIZE/2) is linear in the scale value, which keeps - # it moving smoothly in lockstep with Scale's own eased interpolation - # throughout the animation. A tempting "fix" for a black-border bug at - # strong zoom was dividing this by `top_scale` to pull the shift in at - # high scale values -- that avoided the border, but made the shift a - # *nonlinear* function of the scale being interpolated, so partway - # through the animation the pan no longer tracked the zoom at a - # constant rate -- visibly jerky. Reverted: instead, ZOOM_LEVELS caps - # "strong" at a value where this plain linear formula is already safe - # (verified via the calibration tool), rather than changing the - # formula's shape. + # empirically; an explicit `origin` param has no effect at all). That + # means zooming in crops progressively more off the bottom and right + # than the top and left -- a real cosmetic asymmetry. Pairing Scale + # with a Translate to re-center it was tried (and visually worked), + # but introduced a worse problem: combining Scale + Translate causes + # pixlet to periodically re-rasterize the whole image with a visible + # "pop" (confirmed with both WebP and GIF output, and independent of + # zoom amplitude -- so it's pixlet's animation engine, not this app's + # math). Scale *alone*, with no Translate at all, has none of that + # popping. Given the choice between a subtle, constant crop asymmetry + # and a periodic visible pop, the asymmetry is the lesser issue, so + # this uses Scale only. 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, 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", ), ],