Add full proportional 2x resolution support

Sets supports2x: true in the manifest and threads a single `scale`
value (2 if canvas.is2x() else 1, computed once in main()) through
every size-based constant in the file -- the album art decode size,
art slot, scanline spacing/thickness, corner tick size/thickness,
jitter shift magnitude, RGB-split band positions/heights, text column
width, progress bar, and separator line -- rather than hardcoding
separate 1x/2x numbers. Tick-based timing (durations, hold ticks) is
deliberately left unscaled, since those are real time, not space.

This is what actually makes 2x worth something for the album art
specifically: decoding at ART_SIZE * scale pulls genuinely more detail
from the source JPEG (fetched live from the station, usually far higher
resolution than even a doubled target) rather than just letting pixlet
passively pixel-double the final composited image, which was confirmed
empirically to add zero real detail.

GLITCH_STATE_POOL's hand-tuned 1x pixel values are scaled at the point
of use rather than given a separate 2x table: doubling every term in an
already-verified non-overlapping, safe-margin relationship can't change
its sign or introduce a new overlap/exposure that wasn't already ruled
out at 1x.

Title/artist Text widgets are now explicitly pinned to font="tb-8"
(pixlet's own 1x default) rather than left unspecified -- otherwise
they'd have silently picked up pixlet's auto-upsized 2x default font
while every other line (explicitly "tom-thumb") stayed the same size,
an inconsistency this app's existing small-pixel-font identity doesn't
call for. (A render.Root delay halving was tried first, per pixlet's
own 2x-authoring docs, but that only makes sense when Marquee content
actually grows at 2x -- for lines that stay the same size by explicit
font choice, halving the delay would have overcorrected and made them
scroll too fast. Dropped once the real fix was pinning the font instead.)

Verified: 1x payload basically unchanged (296KB baseline -> 313KB,
comfortably under the 460KB Gen1 limit). 2x payload is much larger
(~950KB) since it's now genuinely encoding more real image detail, not
padding -- expected and accepted for the newer/higher-capacity hardware
2x targets, per explicit confirmation this isn't bound by the same
Gen1 buffer limit. Black-border safety reconfirmed at both resolutions
via an isolated jitter+bands-only render (scanlines/corner ticks
excluded, since they legitimately draw black pixels at the exact
corner coordinates sometimes as part of normal operation, which a naive
full-stack corner check can't distinguish from a real margin bug): 0
hits at both 1x and 2x.
This commit is contained in:
poprhythm committed 2026-10-09 22:01:26 -04:00
1 parent a06cdec621
commit 31a0e5dedc
2 files changed
+123 -63

No files matched your search

+122 -63
View File
@@ -5,7 +5,7 @@ Description: Configure with your SUB/WAVE (github.com/perminder-klair/subwave) s
Author: poprhythm
"""
load("render.star", "render")
load("render.star", "render", "canvas")
load("schema.star", "schema")
load("http.star", "http")
load("cache.star", "cache")
@@ -162,6 +162,21 @@ def _zoom_spot(zoom_position, subsonic_id):
return ZOOM_RANDOM_SPOTS[(time_bucket + track_offset) % len(ZOOM_RANDOM_SPOTS)]
def main(config):
# `scale` is the single source of truth for 2x support: 2 when the
# device/preview is rendering at 2x resolution (canvas.is2x()), else
# 1. Every size-based constant in this file is expressed as its 1x
# value and multiplied by `scale` at the point of use, rather than
# hardcoding separate 1x/2x numbers -- this keeps every hand-tuned
# relationship (black-border margins, scanline period divisibility,
# band overlap gaps) proportionally identical at both resolutions,
# since scaling every term in an already-verified-safe linear
# relationship by the same integer factor can't change its sign or
# introduce a new overlap/exposure that wasn't already ruled out at
# 1x. Tick counts/durations are deliberately *not* scaled -- those
# are real time, not space, and should take the same number of
# seconds regardless of resolution.
scale = 2 if canvas.is2x() else 1
station_url = config.get("station_url", DEFAULT_STATION_URL)
if station_url:
station_url = station_url.rstrip("/")
@@ -194,6 +209,8 @@ def main(config):
(progress["elapsed"] < STATIC_OVERLAY_START_SECONDS or progress["remaining"] < STATIC_OVERLAY_END_SECONDS)
)
text_width = ART_SLOT * scale # the non-art half of the canvas is exactly as wide as the art slot
# The show/host and time/temp rows always render, even when empty, so
# they hold a fixed vertical position regardless of which fields a given
# track/moment actually has data for -- rather than the layout shifting
@@ -203,20 +220,20 @@ def main(config):
show_host_row.append(render.Text(" " + now["host"], font = "tom-thumb", color = colors["accent"]))
top_lines = [
render.Marquee(width = 32, child = render.Text(now["title"], color = colors["text"])),
render.Marquee(width = 32, child = render.Text(now["artist"], color = colors["text"])),
render.Box(width = 32, height = 1, color = colors["separator"]),
render.Marquee(width = 32, child = render.Row(children = show_host_row)),
render.Marquee(width = 32, child = render.Text(now["time_temp"], font = "tom-thumb", color = colors["meta"])),
render.Marquee(width = text_width, child = render.Text(now["title"], font = "tb-8", color = colors["text"])),
render.Marquee(width = text_width, child = render.Text(now["artist"], font = "tb-8", color = colors["text"])),
render.Box(width = text_width, height = scale, color = colors["separator"]),
render.Marquee(width = text_width, child = render.Row(children = show_host_row)),
render.Marquee(width = text_width, child = render.Text(now["time_temp"], font = "tom-thumb", color = colors["meta"])),
]
children = [render.Column(children = top_lines)]
if progress != None:
children.append(_progress_bar(progress["fraction"], colors, width = 32))
children.append(_progress_bar(progress["fraction"], colors, width = text_width, height = 2 * scale))
text_column = render.Box(
width = 32,
height = 32,
width = text_width,
height = ART_SLOT * scale,
child = render.Column(
main_align = "space_between",
cross_align = "start",
@@ -226,15 +243,15 @@ def main(config):
)
if art:
content = render.Row(children = [_art_widget(art, zoom_scale, zoom_position, now["subsonic_id"], show_static), text_column])
content = render.Row(children = [_art_widget(art, zoom_scale, zoom_position, now["subsonic_id"], show_static, scale), text_column])
else:
content = render.Column(
main_align = "center",
cross_align = "center",
expanded = True,
children = [
render.Marquee(width = 64, child = render.Text(now["title"], color = colors["text"])),
render.Marquee(width = 64, child = render.Text(now["artist"], color = colors["text"])),
render.Marquee(width = canvas.width(), child = render.Text(now["title"], font = "tb-8", color = colors["text"])),
render.Marquee(width = canvas.width(), child = render.Text(now["artist"], font = "tb-8", color = colors["text"])),
],
)
@@ -242,7 +259,7 @@ def main(config):
def _with_background(child, colors):
return render.Stack(children = [
render.Box(width = 64, height = 32, color = colors["background"]),
render.Box(width = canvas.width(), height = canvas.height(), color = colors["background"]),
child,
])
@@ -284,9 +301,9 @@ 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, zoom_position, subsonic_id, show_static):
zoom = _zoom_transformation(art_bytes, zoom_scale, zoom_position, subsonic_id)
cropped = render.Box(width = ART_SLOT, height = ART_SLOT, child = zoom)
def _art_widget(art_bytes, zoom_scale, zoom_position, subsonic_id, show_static, scale):
zoom = _zoom_transformation(art_bytes, zoom_scale, zoom_position, subsonic_id, scale)
cropped = render.Box(width = ART_SLOT * scale, height = ART_SLOT * scale, child = zoom)
if not show_static:
return cropped
@@ -298,11 +315,20 @@ def _art_widget(art_bytes, zoom_scale, zoom_position, subsonic_id, show_static):
# _glitch_rest_art for why that was worth avoiding). The live zoom on
# the base `cropped` layer underneath keeps running throughout; once
# the glitch window ends, that's what's left showing.
rest_art = _glitch_rest_art(art_bytes, zoom_position, subsonic_id)
return render.Stack(children = [cropped, _glitch_effect(rest_art)])
rest_art = _glitch_rest_art(art_bytes, zoom_position, subsonic_id, scale)
return render.Stack(children = [cropped, _glitch_effect(rest_art, scale)])
def _zoom_transformation(art_bytes, zoom_scale, zoom_position, subsonic_id):
art = render.Image(src = art_bytes, width = ART_SIZE, height = ART_SIZE)
def _zoom_transformation(art_bytes, zoom_scale, zoom_position, subsonic_id, scale):
# Decoding at ART_SIZE * scale (not a fixed ART_SIZE) is what actually
# makes 2x worth anything for the art specifically: the source JPEG
# fetched live from the station is typically much higher resolution
# than even a doubled target, so a bigger decode genuinely pulls in
# more real detail rather than just stretching the same 36x36-logical-
# pixel result pixlet would otherwise produce by passively upscaling
# the final composited image (confirmed empirically: a non-2x-aware
# render, downscaled back with nearest-neighbor, matched the 1x render
# exactly -- no extra detail, just bigger blocks).
art = render.Image(src = art_bytes, width = ART_SIZE * scale, height = ART_SIZE * scale)
# Always route through animation.Transformation, even when zoom is off
# (a degenerate animation whose two keyframes are both Scale(1.0, 1.0),
@@ -438,7 +464,7 @@ _GLITCH_CYCLE_TICKS = GLITCH_STATES_PER_CYCLE * GLITCH_HOLD_TICKS + GLITCH_PAUSE
_GLITCH_TICKS_PER_SECOND = 20 # 1000ms / 50ms-per-tick
GLITCH_REPEATS = int(GLITCH_ACTIVE_SECONDS * _GLITCH_TICKS_PER_SECOND / _GLITCH_CYCLE_TICKS) + 1
def _glitch_rest_art(art_bytes, zoom_position, subsonic_id):
def _glitch_rest_art(art_bytes, zoom_position, subsonic_id, scale):
# A separate, non-animated rendering of the art for the glitch segment
# to jitter, instead of reusing the live `zoom` that's actually
# mid-cycle on the base layer underneath. Simpler than keeping the
@@ -458,7 +484,7 @@ def _glitch_rest_art(art_bytes, zoom_position, subsonic_id):
# different attempts to fix it by bumping the scale (1.15 breathing,
# then a constant 1.15) each made the border worse, not better, which
# was the tell that scale was never the real problem.
return _zoom_transformation(art_bytes, 1.0, zoom_position, subsonic_id)
return _zoom_transformation(art_bytes, 1.0, zoom_position, subsonic_id, scale)
def _glitch_states_for_cycle(cycle_index):
# Rotate through GLITCH_STATE_POOL instead of slicing (which doesn't
@@ -477,19 +503,26 @@ def _glitch_shift(zoom, shift_x):
# +-2px, at both rest and peak zoom) before building this in.
return render.Padding(pad = (max(0, shift_x), 0, max(0, -shift_x), 0), child = zoom)
def _glitch_band(zoom, top, height, shift_x, hue_change):
def _glitch_band(zoom, top, height, shift_x, hue_change, scale):
# top/height/shift_x arrive already multiplied by `scale` (see
# _glitch_jitter, which scales GLITCH_STATE_POOL's 1x values once at
# the point of use) -- doubling every term in an already-verified
# non-overlapping, safe-margin relationship can't change its sign or
# introduce a new overlap/exposure that wasn't already ruled out at
# 1x, so the pool itself doesn't need a separate 2x copy.
art_slot = ART_SLOT * scale
tinted = filter.Hue(child = _glitch_shift(zoom, shift_x), change = hue_change)
band = render.Padding(
pad = (0, top, 0, max(0, ART_SLOT - top - height)),
child = render.Box(width = ART_SLOT, height = height, child = tinted),
pad = (0, top, 0, max(0, art_slot - top - height)),
child = render.Box(width = art_slot, height = height, child = tinted),
)
# render.Padding *grows* its child's declared size by the pad amount
# rather than nudging content within a fixed frame -- without this
# outer Box to clip it back down to exactly ART_SLOT, the horizontal
# outer Box to clip it back down to exactly art_slot, the horizontal
# shift above made this (and everything stacked with it) wider than
# the 32px art slot, bleeding into the text column next to it.
return render.Box(width = ART_SLOT, height = ART_SLOT, child = band)
# the art slot, bleeding into the text column next to it.
return render.Box(width = art_slot, height = art_slot, child = band)
def _glitch_base_jitter(zoom, shift_x):
# The art itself jittering in sync with the RGB-split bands (not just
@@ -498,9 +531,14 @@ def _glitch_base_jitter(zoom, shift_x):
# along with the color split. Uses the same safe raw-zoom shift
# technique as the bands above, just without the hue tint or height
# crop, so it reads as the full image nudging rather than a band.
#
# Dead code (nothing currently calls this -- _glitch_jitter inlines
# the equivalent Box(_glitch_shift(...)) directly), kept at its
# original 1x-only ART_SLOT for whenever it's revived rather than
# scaled speculatively.
return render.Box(width = ART_SLOT, height = ART_SLOT, child = _glitch_shift(zoom, shift_x))
def _glitch_hold(child, duration):
def _glitch_hold(child, duration, scale):
# A no-op Transformation purely to pin a Sequence child's FrameCount
# to `duration` ticks, so render.Sequence holds on it for that long
# before advancing to the next child -- Sequence has no other way to
@@ -520,15 +558,15 @@ def _glitch_hold(child, duration):
return animation.Transformation(
child = child,
duration = duration,
width = ART_SLOT,
height = ART_SLOT,
width = ART_SLOT * scale,
height = ART_SLOT * scale,
keyframes = [
animation.Keyframe(percentage = 0.0, transforms = []),
animation.Keyframe(percentage = 1.0, transforms = []),
],
)
def _glitch_jitter(zoom):
def _glitch_jitter(zoom, scale):
# Built as a render.Sequence of discrete shifted states, each using
# _glitch_shift (Padding-based), not animation.Transformation +
# Translate (an approach tried in between). Translate seemed
@@ -549,14 +587,22 @@ def _glitch_jitter(zoom):
# frame-dependent zoom that version wrapped. That no longer applies:
# `zoom` here is _glitch_rest_art's static, non-animated image, so
# there's nothing frame-dependent left for Sequence to desync.
pause = _glitch_hold(render.Box(width = ART_SLOT, height = ART_SLOT, child = zoom), GLITCH_PAUSE_TICKS)
art_slot = ART_SLOT * scale
pause = _glitch_hold(render.Box(width = art_slot, height = art_slot, child = zoom), GLITCH_PAUSE_TICKS, scale)
children = []
for cycle_index in range(GLITCH_REPEATS):
for top1, h1, shift1, hue1, top2, h2, shift2, hue2 in _glitch_states_for_cycle(cycle_index):
# Capped to +-1px -- a full-frame shift reads very differently
# than a thin band shifting, so it stays subtle. Randomized
# rather than derived from shift1/shift2 (several
# GLITCH_STATE_POOL's top/height/shift values are 1x pixels,
# scaled here at the point of use -- see _glitch_band for why
# that's safe (doubling every term in an already-verified
# relationship preserves it).
top1, h1, shift1 = top1 * scale, h1 * scale, shift1 * scale
top2, h2, shift2 = top2 * scale, h2 * scale, shift2 * scale
# Capped to +-1px (1x) -- a full-frame shift reads very
# differently than a thin band shifting, so it stays subtle.
# Randomized rather than derived from shift1/shift2 (several
# GLITCH_STATE_POOL entries have those nearly or exactly
# cancel out, e.g. -2 and 2, which silently zeroed out the
# jitter in about half the states when this was tried before).
@@ -570,13 +616,13 @@ def _glitch_jitter(zoom):
# padded, or (once the margin was kept genuinely untinted) as
# a real black border. Plain shift, no extra color tint on
# the base jitter -- confirmed 0 black-corner hits.
shift = random.number(0, 2) - 1 # random.number requires min >= 0
shift = (random.number(0, 2) - 1) * scale # random.number requires min >= 0
state = render.Stack(children = [
render.Box(width = ART_SLOT, height = ART_SLOT, child = _glitch_shift(zoom, shift)),
_glitch_band(zoom, top1, h1, shift1, hue1),
_glitch_band(zoom, top2, h2, shift2, hue2),
render.Box(width = art_slot, height = art_slot, child = _glitch_shift(zoom, shift)),
_glitch_band(zoom, top1, h1, shift1, hue1, scale),
_glitch_band(zoom, top2, h2, shift2, hue2, scale),
])
children.append(_glitch_hold(state, GLITCH_HOLD_TICKS))
children.append(_glitch_hold(state, GLITCH_HOLD_TICKS, scale))
children.append(pause)
return render.Sequence(children = children)
@@ -600,7 +646,6 @@ def _glitch_jitter(zoom):
# alternative to this small-sawtooth approach) isn't viable either;
# small periodic shifts are the only mechanism that actually works here.
SCANLINE_COUNT = 4
SCANLINE_SPACING = ART_SLOT // SCANLINE_COUNT
# Sized against the full show_static window (GLITCH_ACTIVE_SECONDS), not
# a fixed 8s -- it used to be a flat 160 ticks while GLITCH_ACTIVE_SECONDS
@@ -617,7 +662,7 @@ _SCANLINE_CYCLE_TICKS = 20 # 1s per sawtooth cycle at the default 50ms/tick
SCANLINE_SWEEP_DURATION = GLITCH_ACTIVE_SECONDS * _GLITCH_TICKS_PER_SECOND
SCANLINE_CYCLES = SCANLINE_SWEEP_DURATION // _SCANLINE_CYCLE_TICKS
def _glitch_scanlines():
def _glitch_scanlines(scale):
# Modeled on SUB/WAVE's own scanlines, which sweep continuously via
# an animated `background-position-y` rather than sitting still.
#
@@ -642,13 +687,23 @@ def _glitch_scanlines():
# is invisible -- a standard infinite-scrolling-texture trick -- while
# the translate distance never has to leave the small range already
# confirmed safe.
tall = ART_SLOT + SCANLINE_SPACING
#
# SCANLINE_SPACING is computed from the *scaled* art_slot (not a
# fixed 1x constant) so it stays the true repeat period at either
# resolution -- SCANLINE_COUNT=4 still divides it evenly (32/4=8 at
# 1x, 64/4=16 at 2x), which is what the sweep's seamless loop depends
# on (see the note above SCANLINE_COUNT for why that divisibility
# matters). Each line is `scale` rows thick (not a fixed 1px), so the
# line's own weight stays proportional to the bigger canvas too.
art_slot = ART_SLOT * scale
scanline_spacing = art_slot // SCANLINE_COUNT
tall = art_slot + scanline_spacing
lines = []
for y in range(tall):
if y % SCANLINE_SPACING == 0:
lines.append(render.Box(width = ART_SLOT, height = 1, color = "#00000080"))
if y % scanline_spacing < scale:
lines.append(render.Box(width = art_slot, height = 1, color = "#00000080"))
else:
lines.append(render.Box(width = ART_SLOT, height = 1, color = "#00000000"))
lines.append(render.Box(width = art_slot, height = 1, color = "#00000000"))
pattern = render.Column(children = lines)
# The ramp's end and the next cycle's snap-back share the exact same
@@ -667,15 +722,16 @@ def _glitch_scanlines():
))
keyframes.append(animation.Keyframe(
percentage = float(cycle_end) / SCANLINE_SWEEP_DURATION,
transforms = [animation.Translate(0, SCANLINE_SPACING)],
transforms = [animation.Translate(0, scanline_spacing)],
curve = "linear",
))
sweeping = animation.Transformation(child = pattern, duration = SCANLINE_SWEEP_DURATION, keyframes = keyframes)
return render.Box(width = ART_SLOT, height = ART_SLOT, child = sweeping)
return render.Box(width = art_slot, height = art_slot, child = sweeping)
def _glitch_corner_ticks(color):
tick_size = 5
def _glitch_corner_ticks(color, scale):
tick_size = 5 * scale
line_thickness = scale # was a fixed 1px; scaled so the bracket's own weight stays proportional
# A single tick shape (two short perpendicular lines) always draws
# the same way regardless of which corner it's placed in -- it has no
@@ -688,19 +744,21 @@ def _glitch_corner_ticks(color):
# child's declared size, so without this the tick would be wider/
# taller than tick_size.
def tick(top_aligned, left_aligned):
far_edge = tick_size - line_thickness
h_line = render.Padding(
pad = (0, 0 if top_aligned else tick_size - 1, 0, tick_size - 1 if top_aligned else 0),
child = render.Box(width = tick_size, height = 1, color = color),
pad = (0, 0 if top_aligned else far_edge, 0, far_edge if top_aligned else 0),
child = render.Box(width = tick_size, height = line_thickness, color = color),
)
v_line = render.Padding(
pad = (0 if left_aligned else tick_size - 1, 0, tick_size - 1 if left_aligned else 0, 0),
child = render.Box(width = 1, height = tick_size, color = color),
pad = (0 if left_aligned else far_edge, 0, far_edge if left_aligned else 0, 0),
child = render.Box(width = line_thickness, height = tick_size, color = color),
)
return render.Box(width = tick_size, height = tick_size, child = render.Stack(children = [h_line, v_line]))
art_slot = ART_SLOT * scale
return render.Box(
width = ART_SLOT,
height = ART_SLOT,
width = art_slot,
height = art_slot,
child = render.Column(
main_align = "space_between",
expanded = True,
@@ -717,14 +775,15 @@ def _glitch_corner_ticks(color):
),
)
def _glitch_effect(zoom):
def _glitch_effect(zoom, scale):
art_slot = ART_SLOT * scale
return render.Box(
width = ART_SLOT,
height = ART_SLOT,
width = art_slot,
height = art_slot,
child = render.Stack(children = [
_glitch_jitter(zoom),
_glitch_scanlines(),
_glitch_corner_ticks("#00000080"),
_glitch_jitter(zoom, scale),
_glitch_scanlines(scale),
_glitch_corner_ticks("#00000080", scale),
]),
)
Reference in new issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.