diff --git a/subwave_nowplaying.star b/subwave_nowplaying.star index 4896ab4..e2ccab3 100644 --- a/subwave_nowplaying.star +++ b/subwave_nowplaying.star @@ -223,23 +223,37 @@ def _art_widget(art_bytes, zoom_scale): # don't actually agree on framing despite both nominally being "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 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 that's a deterministic function of the same + # scale value (`shift`) re-centers the zoom so it crops evenly from + # all sides instead -- verified against a calibration grid image. + # This is safe because scale never drops below 1.0 here: the + # black-border bug from an earlier version came from combining + # Translate with scale *below* 1.0, not from Translate itself. 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)], + transforms = [animation.Scale(1.0, 1.0), animation.Translate(0, 0)], ), animation.Keyframe( percentage = 0.5, - transforms = [animation.Scale(top_scale, top_scale)], + 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)], + transforms = [animation.Scale(1.0, 1.0), animation.Translate(0, 0)], curve = "ease_in_out", ), ],