Revert centered zoom; Scale+Translate causes a periodic rendering pop
Combining animation.Scale with animation.Translate to center the zoom (two commits back) visually fixed the top-left crop bias, but introduced a worse problem: pixlet periodically re-rasterizes the whole image with a visible "pop" roughly every 0.7-1s, confirmed with both WebP and GIF output and independent of zoom amplitude -- a property of pixlet's animation engine when Scale and Translate are combined, not something fixable from this app's math. Scale alone, with no Translate, has none of this popping (confirmed via isolated testing). Reverted _art_widget to Scale-only (as it was before centering), and restored ZOOM_LEVELS["strong"] to 1.7 (the strong-zoom black border only applied to the Translate-based centering, not plain Scale). This brings back the original top-left crop bias as a known, accepted cosmetic trade-off -- better than a periodic visible pop. Updated the README and dev/calibration_test.star to match and documented the full history for anyone who wants to revisit centering later.
This commit is contained in:
1 parent
5f42319e26
commit
9f63adbfbf
3 files changed
+79
-77
No files matched your search
@@ -34,45 +34,52 @@ 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 the full
|
- Album art gets a slow "Ken Burns" breathing effect: starts and ends on
|
||||||
frame in to a zoomed-in crop of the top-left corner, then back out, on a
|
the full frame, easing in to a zoomed-in crop at the midpoint, on a
|
||||||
continuous ~60s round-trip loop (not a one-shot reveal — see below).
|
continuous ~10s round-trip loop (not a one-shot reveal — see below). How
|
||||||
Rendered oversized (45x45 vs. the 32x32 display slot) since
|
far it zooms in is configurable (Off/Subtle/Moderate/Strong — see
|
||||||
`animation.Scale` has no visible effect on a widget whose declared size
|
Configuration). Rendered oversized (36x36 vs. the 32x32 display slot,
|
||||||
already matches its container, and scaled between 1.0 and 1.4 — only ever
|
just enough headroom for `animation.Scale` to have a visible effect)
|
||||||
scaling *up*, never down. That's a fix for a real on-device bug: an
|
and scaled *up only*, from 1.0 to the configured zoom amount, never
|
||||||
earlier version zoomed *out* from a corner (scaling down from 1.0 toward
|
below 1.0 — scaling down was tried (to make the rest state reveal the
|
||||||
~0.71, reached via `animation.Translate`), which showed up on a real
|
literal whole image, or to re-center an off-center crop) and both
|
||||||
device as a black border cutting into the art. Empirical testing (against
|
attempts produced a real, on-device black border; see the fuller
|
||||||
the actual Row layout, at native canvas size) showed the outer `Box`
|
history below.
|
||||||
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 version looked jumpy in
|
`animation.Scale`'s pivot defaults to the top-left corner (confirmed
|
||||||
practice — since the app re-renders on `NOWPLAYING_TTL`, each fresh
|
empirically — an explicit `origin` parameter has no effect at all), so
|
||||||
render restarts its own animation from tick 0, so whatever the *previous*
|
zooming in crops a bit more off the bottom/right than the top/left. A
|
||||||
pushed image happened to be showing gets abruptly replaced by a fresh
|
centered version (pairing Scale with a computed `animation.Translate`)
|
||||||
zoomed-in frame. Looping back-and-forth doesn't eliminate that (a
|
was built and visually worked, but combining Scale with Translate turned
|
||||||
server-side re-render always restarts at tick 0 regardless of app logic),
|
out to make pixlet periodically re-rasterize the whole image with a
|
||||||
but ending exactly where it started means the animation also loops
|
visible "pop" every ~0.7-1s — confirmed with both WebP and GIF output,
|
||||||
seamlessly *on-device* between refreshes, which reads as far less jarring
|
independent of zoom amplitude, so it's a property of pixlet's animation
|
||||||
than a one-way reveal that snaps back. Considered using genuinely random
|
engine, not this app's math. Scale *alone* has none of that popping.
|
||||||
per-frame content (like `ormakers-tronbyt`'s LoRa segment, which pre-crops
|
Given the choice between a subtle, constant crop asymmetry and a
|
||||||
60 frames directly from a high-resolution *static* source photo via
|
periodic visible pop, this app accepts the asymmetry. (`dev/calibration_test.star`
|
||||||
offline Pillow processing) for a sharper zoom, but that technique
|
has a working centered version if anyone wants to revisit this trade-off.)
|
||||||
fundamentally doesn't transfer here: album art is fetched live per-track
|
Verified payload stays well under the 460KB device limit at every zoom
|
||||||
at render time, and pixlet's Starlark sandbox has no image-cropping/
|
level, measured against the live station.
|
||||||
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
|
**Why a loop, not a one-shot reveal, and why it's short:** a one-way
|
||||||
source we don't have until the request hits the live station.
|
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
|
- 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 —
|
||||||
|
|||||||
+17
-10
@@ -1,15 +1,11 @@
|
|||||||
"""
|
"""
|
||||||
Dev tool, not part of the deployed app: renders dev/calibration.png through
|
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
|
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,
|
can visually check how much margin gets cropped at rest and at full zoom.
|
||||||
and whether the crop is even on all sides.
|
|
||||||
|
|
||||||
calibration.png is a 360x360 grid: concentric 10%-wide color rings (magenta
|
calibration.png is a 360x360 grid: concentric 10%-wide color rings (magenta
|
||||||
outermost, then yellow, then cyan) plus four corner markers (red TL, green
|
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
|
TR, blue BL, white BR) and a center crosshair.
|
||||||
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.
|
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
pixlet render dev/calibration_test.star -o calib.gif
|
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
|
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.
|
||||||
|
|
||||||
|
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")
|
load("render.star", "render")
|
||||||
@@ -30,14 +38,13 @@ ART_ZOOM_DURATION = 200
|
|||||||
def _art_widget(art_bytes, zoom_scale):
|
def _art_widget(art_bytes, zoom_scale):
|
||||||
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,
|
||||||
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)
|
||||||
|
|||||||
+17
-29
@@ -91,16 +91,12 @@ 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"
|
||||||
|
|
||||||
@@ -229,43 +225,35 @@ def _art_widget(art_bytes, zoom_scale):
|
|||||||
# 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 fixed at the top-left corner (confirmed
|
# animation.Scale's pivot is fixed at the top-left corner (confirmed
|
||||||
# empirically; an explicit `origin` param has no effect at all). Left
|
# empirically; an explicit `origin` param has no effect at all). That
|
||||||
# alone, that means zooming in crops progressively *more* off the
|
# means zooming in crops progressively more off the bottom and right
|
||||||
# bottom and right than the top and left -- most of the animation's
|
# than the top and left -- a real cosmetic asymmetry. Pairing Scale
|
||||||
# duration is spent between keyframes, not sitting at the symmetric
|
# with a Translate to re-center it was tried (and visually worked),
|
||||||
# rest state, so this read as a persistent bottom/right crop. Pairing
|
# but introduced a worse problem: combining Scale + Translate causes
|
||||||
# Scale with a Translate re-centers the zoom so it crops evenly from
|
# pixlet to periodically re-rasterize the whole image with a visible
|
||||||
# all sides instead -- verified against a calibration grid image.
|
# "pop" (confirmed with both WebP and GIF output, and independent of
|
||||||
#
|
# zoom amplitude -- so it's pixlet's animation engine, not this app's
|
||||||
# shift = (1-s)*(ART_SIZE/2) is linear in the scale value, which keeps
|
# math). Scale *alone*, with no Translate at all, has none of that
|
||||||
# it moving smoothly in lockstep with Scale's own eased interpolation
|
# popping. Given the choice between a subtle, constant crop asymmetry
|
||||||
# throughout the animation. A tempting "fix" for a black-border bug at
|
# and a periodic visible pop, the asymmetry is the lesser issue, so
|
||||||
# strong zoom was dividing this by `top_scale` to pull the shift in at
|
# this uses Scale only.
|
||||||
# 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.
|
|
||||||
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,
|
||||||
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",
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||