From a4a8e372429e2fac1e91c8bacbee1759ca262277 Mon Sep 17 00:00:00 2001 From: jim kolpack Date: Sat, 10 Oct 2026 14:23:33 -0400 Subject: [PATCH] Fix asymmetric album art crop -- was showing more top-left, losing bottom/right Root-caused with the calibration tool's crosshair marker: at rest and near-rest (zoom breathing cycle's endpoints, where Scale is momentarily pinned to a constant value), animation.Origin has no effect at all -- confirmed empirically, varying origin_x/origin_y produced byte-identical output whenever Scale doesn't actually change frame to frame, regardless of the scale value itself. With nothing for Origin to pivot, the crop fell back to render.Box's own default handling of an oversized Transformation child, which isn't centered: the crosshair sat several pixels down-and-right of true center, meaning the art was effectively showing more of its own top-left and cropping away a disproportionate chunk of the bottom-right -- exactly "losing ~10% on bottom and right, when it's not zoomed". Fixed with a *constant* Translate (same value at every keyframe, not animated) -- found empirically via the calibration tool (the (tx, ty) that centered the crosshair at rest also held up at peak zoom, checked against both). This isn't the mistake an earlier, abandoned attempt made: that version's Translate *varied* over the animation fighting Origin's own pivot, and interacted badly with Scale's continuous interpolation (the historical "periodic pop" bug, per pixlet's own source on Translate's per-frame rounding). A constant value never changes frame to frame, so there's nothing for it to round-mismatch against -- confirmed smooth across a full cycle. Scaled by `scale` (box_crop_bias = -2 * scale) to stay correct at 2x. dev/calibration_test.star updated to match, per its own sync requirement with the real _zoom_transformation. Verified: crosshair now dead-center at rest (calibration tool), 0 black-corner hits for jitter+bands in isolation at worst-case settings (strong zoom, random position) at both 1x and 2x, and 0 black/void corners across a full zoom cycle at all four ZOOM_RANDOM_SPOTS values with strong zoom. --- dev/calibration_test.star | 20 ++++++++++++--- subwave_nowplaying.star | 53 +++++++++++++++++++++++++++++++-------- 2 files changed, 59 insertions(+), 14 deletions(-) diff --git a/dev/calibration_test.star b/dev/calibration_test.star index 8b08a22..768d53c 100644 --- a/dev/calibration_test.star +++ b/dev/calibration_test.star @@ -32,6 +32,19 @@ 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. + +Later: `origin` turned out to only have any effect while Scale is +actually changing frame to frame (confirmed with this tool: varying +origin_x/origin_y produced identical output for a Scale pinned to a +constant value at every keyframe). At the two instants this animation's +own Scale is momentarily constant (percentage 0.0 and 1.0, "rest"), the +crop falls back to render.Box's own default handling of an oversized +Transformation child, which isn't centered -- the crosshair below sat +several pixels toward the bottom-right of center. Fixed with BOX_CROP_BIAS, +a *constant* Translate (same value at every keyframe, found via this +tool's crosshair) -- not the same mistake as the first attempt above, +since a constant value never changes frame to frame, so there's nothing +for it to round-mismatch against Scale's interpolation. """ load("render.star", "render") @@ -41,6 +54,7 @@ load("calibration.png", CALIB = "file") ART_SIZE = 36 ART_SLOT = 32 ART_ZOOM_DURATION = 200 +BOX_CROP_BIAS = -2 # see the docstring above; keep in sync with box_crop_bias in the real _zoom_transformation def _art_widget(art_bytes, zoom_scale, origin_x, origin_y): art = render.Image(src = art_bytes, width = ART_SIZE, height = ART_SIZE) @@ -50,9 +64,9 @@ def _art_widget(art_bytes, zoom_scale, origin_x, origin_y): duration = ART_ZOOM_DURATION, origin = animation.Origin(origin_x, origin_y), keyframes = [ - 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"), + animation.Keyframe(percentage = 0.0, transforms = [animation.Scale(1.0, 1.0), animation.Translate(BOX_CROP_BIAS, BOX_CROP_BIAS)]), + animation.Keyframe(percentage = 0.5, transforms = [animation.Scale(top_scale, top_scale), animation.Translate(BOX_CROP_BIAS, BOX_CROP_BIAS)], curve = "ease_in_out"), + animation.Keyframe(percentage = 1.0, transforms = [animation.Scale(1.0, 1.0), animation.Translate(BOX_CROP_BIAS, BOX_CROP_BIAS)], 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 a5150dc..4ebd486 100644 --- a/subwave_nowplaying.star +++ b/subwave_nowplaying.star @@ -343,16 +343,47 @@ def _zoom_transformation(art_bytes, zoom_scale, zoom_position, subsonic_id, scal # `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. + # zoom by pairing Scale with a *computed, animated* 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` for the zoom-position pivot. + # + # Separately, though: `origin` only has any effect while Scale is + # actually changing frame to frame -- confirmed empirically (varying + # origin_x/origin_y produced byte-identical output for a Scale that's + # pinned to the same constant value across every keyframe, at *any* + # scale, not just 1.0). At the two instants where this animation's own + # Scale is momentarily constant (percentage 0.0 and 1.0, i.e. "rest"), + # there's nothing for origin to pivot, so the crop that's left falls + # back to render.Box's own default handling of an oversized + # Transformation child -- which isn't a centered crop at all: checked + # with the calibration tool (concentric rings plus a center crosshair), + # the crosshair sat several pixels down-and-right of where a centered + # crop would put it, i.e. the box was effectively showing more of the + # image's top-left and cropping away a disproportionate amount from + # the bottom-right -- matching exactly what was reported ("losing + # ~10% on bottom and right, not zoomed"). + # + # Fixed with a *constant* Translate -- the same (tx, ty) at every + # keyframe, found empirically via the calibration tool's crosshair + # (the value that put it dead-center at rest also held up at peak + # zoom). This is not the same mistake as the earlier abandoned + # attempt: that Translate *varied* over the animation (fighting + # Origin's own pivot, and per pixlet's own source, interacting badly + # with Scale's continuous interpolation vs. Translate's per-frame + # rounding -- the "periodic pop" bug). A constant value changes + # nothing frame to frame, so there's no interpolation for it to + # mismatch against -- confirmed smooth across a full cycle, no pop. + # It's purely compensating for render.Box's own fixed crop bias, which + # doesn't depend on zoom_scale/zoom_position at all. origin_x, origin_y = _zoom_spot(zoom_position, subsonic_id) top_scale = max(zoom_scale, 1.0) + box_crop_bias = -2 * scale zoom = animation.Transformation( child = art, duration = ART_ZOOM_DURATION, @@ -360,16 +391,16 @@ def _zoom_transformation(art_bytes, zoom_scale, zoom_position, subsonic_id, scal keyframes = [ animation.Keyframe( percentage = 0.0, - transforms = [animation.Scale(1.0, 1.0)], + transforms = [animation.Scale(1.0, 1.0), animation.Translate(box_crop_bias, box_crop_bias)], ), animation.Keyframe( percentage = 0.5, - transforms = [animation.Scale(top_scale, top_scale)], + transforms = [animation.Scale(top_scale, top_scale), animation.Translate(box_crop_bias, box_crop_bias)], curve = "ease_in_out", ), animation.Keyframe( percentage = 1.0, - transforms = [animation.Scale(1.0, 1.0)], + transforms = [animation.Scale(1.0, 1.0), animation.Translate(box_crop_bias, box_crop_bias)], curve = "ease_in_out", ), ],