diff --git a/README.md b/README.md index 5acfad6..15f92a6 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,9 @@ app's config when adding it to a device. muted color for the time/temp line. - **Album art zoom** — how far the album art's breathing zoom effect goes 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 @@ -46,30 +49,39 @@ app's config when adding it to a device. attempts produced a real, on-device black border; see the fuller history below. - `animation.Scale`'s pivot defaults to the *center* of the widget per - pixlet's own docs, so Scale alone is already close to centered — just - slightly skewed toward the top-left as the zoom amount increases. - Paired with a computed `animation.Translate` to cancel out that residual - skew. Combining Scale with Translate at all was tried once before and - reverted after it produced a periodic rendering "pop" roughly every - 0.7-1s — confirmed with both WebP and GIF output, independent of zoom - amplitude. Root-caused to pixlet's own source + Where the zoom centers on is controlled via `animation.Origin`, which + works correctly and does real work here — contrary to an earlier, + mistaken belief in this app's own history that it "had no effect" (that + came from testing it set to its own default value and comparing the + result against itself, which is of course identical either way). The + "Random" zoom position picks from a handful of preset off-center spots + (`ZOOM_RANDOM_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 `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 - nearest whole pixel every frame by default, while Scale is never - rounded, so a continuously-changing Scale combined with a rounded - Translate holds still for several frames and then jumps a whole pixel - at once. Passing `rounding = "none"` on the `Transformation` disables - that snapping and fixes it — confirmed via frame-by-frame pixel-delta - analysis (the amount of change is now even from frame to frame, instead - of long still stretches punctuated by sudden jumps). "strong" is capped - at 1.45 (not pushed further): above ~1.46 the centered crop 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. 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). + nearest whole pixel every frame by default, while Scale never rounds, + so a continuously-changing Scale combined with a rounded Translate + holds still for several frames and then jumps a whole pixel at once. + Since `animation.Origin` already does directly and correctly what that + Translate was clumsily approximating, the Translate was removed + entirely rather than patched further (e.g. with `rounding = "none"`, + which does fix the popping but wasn't needed once Origin replaced it). + Verified payload stays well under the 460KB device limit at every zoom + level and position, 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 diff --git a/dev/calibration_test.star b/dev/calibration_test.star index 1cb9a5c..8b08a22 100644 --- a/dev/calibration_test.star +++ b/dev/calibration_test.star @@ -11,24 +11,27 @@ TR, blue BL, white BR) and a center crosshair. Usage: 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 ox=0.3 oy=0.3 -o calib_offcenter.gif Keep ART_SIZE/ART_SLOT/the zoom math below in sync with the real _art_widget if that ever changes. History, if this ever needs revisiting: a Scale+Translate centered zoom -was tried, then reverted after it produced a periodic rendering "pop" -(confirmed with both WebP and GIF output, independent of zoom amplitude). -Root cause, found in pixlet's own source -(render/animation/translate.go): a Translate's x/y are rounded to the -nearest whole pixel every frame by default (`rounding = "round"`), while -Scale is never rounded -- so a continuously-interpolating Scale combined -with a rounded Translate holds still for several frames, then jumps a -whole pixel at once. Passing `rounding = "none"` on the Transformation -(see below) disables that snapping and fixed it -- confirmed via -frame-by-frame pixel-delta analysis (even per-frame change throughout, -instead of long still stretches punctuated by sudden jumps). If a future -change reintroduces jerkiness, check whether `rounding = "none"` is still -present before suspecting the shift formula. +was tried first and produced a periodic rendering "pop", root-caused to +pixlet's own source (render/animation/translate.go): Translate's x/y are +rounded to the nearest whole pixel every frame by default, while Scale +never rounds, so a continuously-interpolating Scale combined with a +rounded Translate holds still for several frames then jumps a whole pixel +at once. `rounding = "none"` fixed that. But the Translate's *magnitude* +was also wrong: it assumed Scale pivots at the top-left corner by +default, when pixlet's docs say the default `origin` is already the +center (0.5, 0.5) -- so the "centering" Translate was overcorrecting, +visibly skewing the zoom toward the bottom-right instead. Replaced with +`origin` directly (which does, correctly, what that Translate was +clumsily approximating), confirmed via this calibration tool with +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") @@ -39,25 +42,26 @@ ART_SIZE = 36 ART_SLOT = 32 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) 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, - rounding = "none", + origin = animation.Origin(origin_x, origin_y), 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) def main(config): 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 = [ - _art_widget(CALIB.readall(), zoom_scale), + _art_widget(CALIB.readall(), zoom_scale, origin_x, origin_y), render.Box(width = 32, height = 32, color = "#111111"), ])) diff --git a/subwave_nowplaying.star b/subwave_nowplaying.star index 147c75e..a1ce8b4 100644 --- a/subwave_nowplaying.star +++ b/subwave_nowplaying.star @@ -91,19 +91,45 @@ 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" +# 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): station_url = config.get("station_url", DEFAULT_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]) 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: 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: - 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: content = render.Column( main_align = "center", @@ -215,7 +242,7 @@ ART_SIZE = 36 ART_SLOT = 32 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) # 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" # 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 - # pixlet's own docs) -- so zooming in via Scale alone is already close - # to centered, just slightly skewed toward the top-left as zoom - # increases. Pairing Scale with a Translate removes that residual skew. - # - # Root-caused the earlier "periodic pop" bug (found when this was first - # tried) to pixlet's own animation.Transformation source: a Translate's - # x/y are rounded to the nearest whole pixel every frame by default - # (`rounding = "round"`), while Scale is never rounded. Combined with a - # continuously-interpolating Scale, that rounding makes the Translate - # hold still for several frames and then jump a whole pixel at once -- - # a real, confirmed pixlet behavior (see render/animation/translate.go), - # 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. + # `origin` picks where the zoom centers on, which also gives a free + # "zoom to a different spot each time" effect (ZOOM_SPOTS above) by + # just varying it per track. An earlier version tried to recenter the + # zoom by pairing Scale with a *computed* Translate, on the mistaken + # assumption 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 (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 was meant to + # fix. Since `origin` already does directly and correctly what that + # Translate was clumsily approximating, this uses `origin` alone. + origin_x, origin_y = _zoom_spot_for_track(zoom_position, subsonic_id) 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, - rounding = "none", + origin = animation.Origin(origin_x, origin_y), 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", ), ], @@ -405,5 +426,16 @@ def get_schema(): 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"), + ], + ), ], )