tick_size (the bracket's overall footprint/position) still scales with
`scale` so the ticks sit correctly in the corners at either resolution,
but line_thickness is pinned to 1px per request rather than growing to
match -- the ticks stay crisp/thin at 2x instead of thickening.
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.
Restores the real show_static condition (gated on elapsed/remaining
track time) instead of forcing the glitch on for the whole track, which
was only ever meant to make tuning easier during the glitch effect work.
GLITCH_STATE_POOL computed top2 as top1 minus a 3-5px top-to-top gap,
which let magenta (band2) overlap cyan (band1) whenever band2's own
height exceeded that gap -- e.g. h2=4 with a 3px top-to-top gap put
magenta's bottom edge 1px into cyan's territory. Recomputed every entry
so the gap is measured edge-to-edge (top1 - (top2 + h2)), guaranteeing a
real 3-5px of clean space between the two bands regardless of height.
Verified 0 black-corner hits across 263 frames.
GLITCH_STATE_POOL previously had independently-random top positions for
the two bands. Now top2 (hue -60.0, magenta) is always top1 (hue 150.0,
cyan) minus a 3-5px gap, so magenta consistently sits just above cyan
rather than their relative order/spacing varying per state.
Verified 0 black-corner hits across 265 frames, 260KB payload.
SCANLINE_SWEEP_DURATION was still a flat 160 ticks (8s) from when
GLITCH_ACTIVE_SECONDS only covered a short burst -- fine at the time,
since nothing animated past that burst anyway. Once GLITCH_ACTIVE_SECONDS
was extended to cover the full show_static window, this fell behind:
animation.Transformation holds at its last keyframe once its own
declared duration elapses, so the scanline sweep visibly froze partway
through while jitter/bands (sized against the full window) kept
animating -- reported as "scanlines stop, but everything else is
running."
SCANLINE_SWEEP_DURATION is now derived from GLITCH_ACTIVE_SECONDS, same
as jitter already is. _SCANLINE_CYCLE_TICKS (sweep speed) is kept fixed
and SCANLINE_CYCLES derived from it, rather than the reverse, so the
sweep doesn't also slow down as the window gets longer.
Verified 1 negligible black-corner hit across 250 frames (consistent
with the known benign corner-tick color blend, not a structural issue),
327KB worst-case payload (strong zoom + random position), well under
the 460KB limit.
GLITCH_STATE_POOL's height values were 10-13px (sized to reliably show
2-3 scanlines within a band); capped to 2-4px thin slivers instead. No
longer tall enough to guarantee scanline overlap, which is fine -- the
shorter height was the explicit ask.
Verified 0 black-corner hits across 228 frames, payload 291KB (down
from 320KB with the taller bands).
Restores _glitch_band into _glitch_jitter's Sequence states (the
function and its supporting GLITCH_STATE_POOL/_glitch_states_for_cycle
were already in the file, just unused since bands were disabled a few
commits back during the jitter debugging work). Each state is now a
Stack of the base-art shift plus two tinted, shifted bands, matching the
original combined design from before bands were turned off -- just
built on the current, fixed jitter architecture (_glitch_hold-wrapped
Sequence states, operating on the static _glitch_rest_art).
Verified: 0 black-corner hits across 222 frames, 320KB typical / 314KB
worst-case (strong zoom + random position) payload, both comfortably
under the 460KB device limit.
Found by comparing against an earlier commit (4983e81) where jitter was
last confirmed visible, before this file's jitter architecture was
rewritten twice since (once for a live-zoom desync bug, once for a
Translate black-border bug). The shift mechanism (_glitch_shift,
Padding-based) was identical in both versions -- the actual difference
was how a state gets "held" for multiple ticks in the render.Sequence.
The rewritten version held a state by appending the same widget
reference to the Sequence's children list GLITCH_HOLD_TICKS times in a
row. That produced a correctly-encoded, pixel-diffable-as-different file
via plain `pixlet render`, but was reported as showing no jitter at all
through pixlet serve's live preview -- repeated-identical-children
apparently doesn't advance playback the same way every Sequence consumer
expects.
Restored _glitch_hold(child, duration): a no-op animation.Transformation
(empty keyframe transforms) wrapping each state once, pinning its own
FrameCount to `duration` so Sequence holds on a single child instead of
relying on duplicate entries. This is the original, proven-working
pattern from before the rewrites -- unchanged other than operating on
the now-static _glitch_rest_art instead of the live zoom.
Verified 0 black-corner hits across 194 frames, 185KB worst-case payload
(strong zoom + random position), both comfortably within budget.
The hue tint (added to make the +-1px jitter shift more visible) caused
a real, reported visual bug: a solid-colored strip on whichever edge
_glitch_shift's Padding fell on, seen as "a gradient" on the right side
of the art. Root cause: filter.Hue was wrapping the *padded* result, so
it tinted the shift's own blank margin along with the real image
content, instead of leaving that margin neutral.
Tried reordering (tint zoom first, then pad/shift it) to fix that, but
that exposed a worse problem: the padding's true underlying fill is dark
enough that, once genuinely untinted, it reads as actual black border --
34 corner hits across 200 frames, far worse than any previous
configuration.
Reverting the hue tint entirely rather than continuing to patch it.
Plain +-1px shift, no tint, is back -- confirmed 0 black-corner hits
across 200 frames. The jitter's position shift is confirmed mechanically
correct (pixel-diffed in isolation to a clean, alternating 1-column
shift) but stays visually subtle; making it more noticeable without
reopening either the border or an artifact needs a more careful pass,
not another quick layer on top of this mechanism.
The +-1px position shift was confirmed mechanically correct (pixel-diffed
in isolation: a clean, correctly-alternating 1-column shift each state)
but was invisible in practice once scanlines and corner ticks are
layered on top and viewed on a real device -- one column out of 32 just
doesn't read against that much other motion, which is what was being
reported as "no jitter."
Tried +-2px as a more-visible fix first and it reopened the black border
(10 corner hits across 130 frames, vs 0 at +-1px) -- _glitch_shift's
margin isn't big enough for +-2px in every zoom_position/origin
combination. Reverted that and instead paired the existing +-1px shift
with a brief filter.Hue tint on each jittered state (same mechanism
_glitch_band used before the RGB-split bands were disabled) -- a color
change doesn't touch geometry, so it carries none of the margin risk a
bigger shift does, while still making each burst read clearly.
Verified: 2 black-corner hits across 199 frames (both frame 0-1, same
corner) -- consistent with the semi-transparent corner tick's own 50%
black blending with that particular frame's pixel color, not a
reopened margin leak, since the shift magnitude itself is unchanged
from the already-verified-safe +-1px.
GLITCH_ACTIVE_SECONDS was still capped at 6s from when this Sequence
also drove the (since-removed) hue-shifted RGB-split bands, which were
expensive enough that sizing against the full 22s show_static window
pushed a render past the 460KB limit. render.Sequence doesn't loop on
its own, so once those 6s of ticks were exhausted the jitter sequence
just stopped painting -- scanlines and corner ticks (not Sequence-based)
kept going, making it look like jitter had stopped working entirely
once past that opening burst, which is what was being seen.
Now that the jitter alone drives this (bands are gone, shift mechanism
is _glitch_shift/Padding), it compresses far better: measured ~82KB
typical and worst-case even sized against the full
STATIC_OVERLAY_END_SECONDS (22s) window, so there's no longer a reason
to cap it short. Re-verified 0 black-corner hits across 199 frames.
Re-enables the base jitter (disabled two commits ago while isolating the
scanline work) with the actual fix, not another scale tweak -- two
earlier attempts to fix the black border by bumping _glitch_rest_art's
scale (breathing 1.15, then constant 1.15) both made it *worse* (680 and
532 black-corner hits across 200/133 frames, vs. 372 at a literal 1.0),
which was the tell that scale was never the real problem.
Root cause, confirmed via isolated pixel-sampled test renders: pixlet's
animation.Transformation + Translate can't reveal content beyond what's
visible at rest -- it only moves already-visible pixels, it doesn't pull
in new ones -- so no scale on the wrapped content could have fixed a
Translate-based shift. render.Padding, used elsewhere in this file
specifically because it reliably *grows* a widget's declared canvas
(scanlines, the zoom's own ART_SIZE vs ART_SLOT margin), doesn't have
that limitation. _glitch_shift already existed using exactly that
Padding approach and had already been validated safe ("no black border
at +-2px, at both rest and peak zoom") -- it just wasn't what the
current jitter was built on.
Rebuilt _glitch_jitter as a render.Sequence of discrete states built
from _glitch_shift, instead of one animation.Transformation with
Translate keyframes. Sequence was deliberately avoided earlier in this
app's history (it rebases frame index per state, confirmed in pixlet's
own source, which previously desynced a *live*, frame-dependent zoom
nested inside it) -- but that concern doesn't apply here: _glitch_rest_art
(reverted to a plain locked-1.0 scale, since scale was never the issue)
is static and frame-independent, so there's nothing left to desync.
Verified: 0 black-corner hits across 199 frames (was ~370-680 under
every earlier attempt), and payload is now far smaller too (54KB
worst-case at strong zoom + random position, vs. 451KB before) --
Sequence's frame-level deduplication compresses much better than the
continuously-interpolating Transformation did.
The ramp-end keyframe and the next cycle's snap-back-to-0 keyframe were
one tick apart, which got linearly interpolated -- for that one tick the
pattern sat at a fractional, not-quite-zero offset instead of cleanly
snapping, producing a brief flicker at each loop point. Fixed by giving
both keyframes the exact same percentage (a zero-duration jump) instead.
Also drops payload (425KB -> 364KB on the same test render) by removing
a redundant near-duplicate frame per cycle.
6 lines (8 candidate period-4 positions minus a 2-position skip) looked
uneven and glitched during the sweep -- root-caused via pixel-sampled
test renders to the skip breaking the pattern's true repeat period
(became the full 32px instead of 4px), so the small-sawtooth sweep's
shift-by-4-and-snap no longer reproduced an identical-looking frame.
Switched to 4 lines, spacing derived directly from the count
(ART_SLOT // SCANLINE_COUNT = 8px), which divides evenly and keeps the
sweep's shift distance equal to the pattern's actual period -- the only
way the loop stays seamless. Also confirmed (separate pixel-sampled
tests) that a single large full-height sweep isn't viable at all:
pixlet's Transformation+Translate never reveals content beyond what's
visible at rest, so a duplicated-buffer big-sweep approach just goes
blank past a point, same failure mode as the earlier negative-Padding
attempt.
Verified visually (frames crop to 4 evenly-spaced, full-height lines
starting at row 0) and payload stays under the 460KB device limit
(425KB typical, 451KB worst-case at strong zoom + random position --
inflated by the still-active TEMP DEV OVERRIDE forcing the glitch on
for the whole track instead of just the start/end window).
The jitter is genuinely the cause of a black-border bug (confirmed: 0
black-corner hits across 200 frames with it disabled, vs. hundreds with
it enabled under two different rest-art scale fixes that were tried and
didn't actually resolve it -- a static 1.15 scale, and before that
reusing _zoom_transformation's breathing 1.0-to-1.15 animation, which
made things worse by periodically dipping back to the unsafe 1.0 floor).
Root cause not yet fully pinned down. Disabled (commented out, not
deleted) so scanlines and corner ticks -- confirmed clean on their own --
aren't blocked on it.
(8,20) skip set left gaps of 4,8,4,8,4,4 -- uneven, since the trailing
two gaps were both 4 instead of alternating. Removing two slots exactly
opposite each other in the 8-slot cycle (0 and 16) is the most even
spacing achievable while keeping every line on a multiple of
SCANLINE_PERIOD: gaps come out 4,4,8,4,4,8, symmetric.
Skipping the last two of the 8 candidate positions (at y=24, 28) left an
8px gap with nothing in it at the bottom of every 32-row cycle. Skipping
two interspersed positions instead (y=12, 28) spreads the resulting
slightly-larger gaps across the full height instead of clustering them
in one spot.
Reduced visible scanlines from 8 to 6 per window (dropping the last two
of the 8 evenly-spaced SCANLINE_PERIOD positions, rather than changing
the period itself, so it keeps dividing evenly into ART_SLOT).
Also animated the scanlines to sweep downward (previously static),
matching SUB/WAVE's own continuously-animated scanlines. A first attempt
built a buffer twice the art slot's height, pre-shifted with a negative
render.Padding so the middle 32 rows lined up with the window at rest,
then swept the full ART_SLOT distance in one pass. That's what produced
"scanlines disappearing at the bottom without regenerating at the top":
empirically, negative Padding didn't behave like the (well-proven
elsewhere in this file) positive case, which reliably grows a widget's
size -- confirmed via an isolated test that it went blank partway
through a static sweep instead of revealing new rows.
Replaced with a repeating sawtooth instead of one big sweep: the buffer
is only one extra SCANLINE_PERIOD taller than the art slot (the same
small, already-proven-safe oversized margin used throughout this file,
e.g. the zoom's own ART_SIZE vs. ART_SLOT), and the animation ramps down
by exactly one period, snaps back to 0, and repeats -- since the pattern
repeats every period anyway, the snap is invisible (a standard infinite-
scrolling-texture trick), and the translate distance never leaves the
confirmed-safe small range.
Verified via an isolated test (translating a plain Column child by small
amounts, mirroring the technique already used for the zoom/jitter) before
building this in, and via the real render: no black border across all
125 frames of a real render, payload well under budget (193KB).
Root-caused the "big size jumps, not at the same zoom level as the art"
report: the jitter embedded the live `zoom` Transformation inside a
render.Sequence of separately-held states, and Sequence.Paint rebases
the frame index to 0 for whichever child is currently playing (confirmed
in pixlet's own source -- c.Paint(dc, bounds, frameIdx-fc)). Every time
the jitter switched to a new held state (every ~100-150ms), the embedded
zoom saw a near-zero frame index and reset toward the start of its own
10s cycle before snapping back on the next state.
Rather than keep the jitter perfectly synced to the live zoom's current
position (tried via a single Translate-keyframed Transformation instead
of Sequence, which doesn't rebase frame index for its child, and does
work), went with the simpler fix requested instead: the glitch segment
now jitters its own separate, non-animated rendering of the art (scale
locked at 1.0 throughout, via _zoom_transformation's own zoom_scale
argument) rather than the live mid-cycle zoom. Same origin/zoom_position,
so it doesn't jump to a different part of the image when the glitch
starts or ends; the live zoom on the base layer underneath keeps
running throughout regardless, and is what's left showing once the
glitch window ends.
Verified: renders cleanly, no black border across all 109 frames of a
worst-case render (strong zoom, random position, all four corners), and
payload dropped way down (121.6KB worst case, vs. the 460KB device
limit) since the rest-art layer no longer depends on live zoom state,
giving WebP much more to dedupe.
Fixed +1/-1 alternation read as too mechanically regular -- a
predictable back-and-forth. Now picks randomly from {-1, 0, 1} per
burst state via random.star (random.number(0, 2) - 1, since
random.number requires a non-negative minimum).
Commented out the two _glitch_band calls per request (RGB-split bands
off), re-enabled _glitch_scanlines.
Also found and fixed a real bug in the base jitter: base_shift was
derived by averaging each state's shift1/shift2 values, but several
GLITCH_STATE_POOL entries have those nearly or exactly cancel out (e.g.
-2 and 2 average to 0), silently zeroing out the jitter in about half
the burst states -- likely why it wasn't visible. Now alternates a fixed
+1/-1 by position in the sequence instead, so it's never zero.
Re-enabled _glitch_base_jitter per request, but capped the shift to
+-1px instead of splitting the difference between the two bands' own
(larger) shift values -- a full-frame shift reads very differently than
a thin band shifting, so it needed to stay subtle. Confirmed no black
border across a full worst-case render (strong zoom, random position,
all 200 frames, all four corners).
Also sped up the RGB-split jitter (GLITCH_HOLD_TICKS 5 -> 2, ~100ms per
state) and shortened the pause between bursts (GLITCH_PAUSE_TICKS
15 -> 3, ~150ms). Payload stayed comfortably under budget despite both
changes adding more distinct frames to encode: 355KB worst case (strong
zoom + random position), vs. the 460KB device limit.
Disabled the _glitch_base_jitter call added last commit (shifting the
underlying art in sync with the RGB-split bands) -- left the function
and the base_shift calculation in place, just commented out of the
Stack, rather than deleting them, in case it's worth revisiting.
SUB/WAVE's original effect shifts the whole cover frame along with the
color-split bands, not just the bands moving over a static image
underneath. Added that: each burst state now includes a shifted (but
untinted, uncropped) full copy of the art beneath the two RGB-split
bands, nudged by the average of the two bands' own shift values.
Applying the shift to the already-cropped ART_SLOT x ART_SLOT image (the
way the bands used to work) would have exposed a blank edge -- the same
black-border problem from much earlier in this project. Instead, the
shift is applied to the *raw* zoom Transformation before its final crop,
reusing the same oversized margin the zoom animation itself relies on.
Confirmed safe both in an isolated test (+-2px at rest and peak zoom,
no border) and by scanning all 200 frames of a real worst-case render
(strong zoom, random position) for black/transparent corner pixels --
none found.
This required exposing the zoom Transformation itself to the glitch
code instead of just the final cropped image, so _zoomed_art became
_zoom_transformation (returns the raw widget; callers wrap it in the
ART_SLOT Box themselves).
Payload note: worst case (strong zoom + random position) measured 428KB,
up from ~220KB before this change but still under the 460KB device
limit -- margin is noticeably thinner now (~7%) than it's been
recently, worth keeping an eye on if more effects get added later.
User reported the progress bar consistently stalling a few segments
short of full -- SUB/WAVE's reported track duration appears to run a bit
longer than the station actually plays before crossfading/cutting to the
next track, so "remaining" rarely if ever dropped below the old 5s
end-window before the subsonic_id changed out from under it. Split the
single STATIC_OVERLAY_SECONDS into separate start (5s, unchanged) and end
(22s) windows to compensate.
Widening the end window naively (scaling GLITCH_REPEATS to cover all 22s
of it) pushed a real render to 481KB, over the 460KB device limit --
each repeat adds several more uniquely-tinted/shifted frames for WebP to
encode, and 22s of continuous jitter is a lot of those. Added
GLITCH_ACTIVE_SECONDS (6s) to cap how much of the window actually gets
jitter bursts, independent of how long show_static itself stays true --
scanlines and corner ticks are cheap, constant per-pixel layers that
still cover the full window on their own.
Verified: payload stays well under budget (max 234KB) across every zoom
level/position combination after the fix.
Heights were down to 4-7px after the last shortening pass, which (given
scanlines repeat every 4px) could land entirely between two scanline
rows and show none at all, depending on exact alignment. Bumped back up
to 10-13px, which reliably shows 2-3 scanlines regardless of phase.
GLITCH_STATES was a fixed list of 4 band configs that every repeat cycle
played identically, so the jitter looked the same each time it looped.
Expanded to an 8-entry GLITCH_STATE_POOL and rotate through a different
4-state window each cycle (wrapping back to the start via modular
indexing, since Starlark list slicing doesn't wrap) -- verified via an
isolated two-cycle test that cycle 2's band positions genuinely differ
from cycle 1's, not just coincidentally similar-looking.
Also added alpha transparency to the corner tick color (#33ff9980) so
they read as a subtle HUD accent rather than a solid opaque overlay.
The jitter previously played its 4 states once (~1s total) then went
quiet for the rest of the 5s static window, since render.Sequence doesn't
loop. Now repeats the burst-then-pause pattern enough times to cover the
whole window: GLITCH_REPEATS is computed from STATIC_OVERLAY_SECONDS so
it still fully covers the window if either changes, rather than a
hardcoded repeat count silently falling short.
Verified via an isolated single-cycle test (burst of 4 states + 1 pause,
outside the main zoom animation) that the pause frame is genuinely clean
(no tint bands) -- pixlet collapses each multi-tick hold into a single
deduped output frame, which made an earlier attempt to verify this by
guessing raw frame indices unreliable; confirmed instead by exhaustively
inspecting all 5 of the isolated test's (correctly deduped) output
frames.
render.Padding grows its child's declared size by the pad amount rather
than nudging content within a fixed frame. _glitch_band's horizontal
shift_x relied on Padding without ever clipping the result back down to
ART_SLOT, so the RGB-split bands (and everything stacked with them) ended
up wider than the 32px art slot, bleeding into the text column next to
it. Fixed by wrapping each band (and the Sequence states, and the overall
effect) in an explicit Box(ART_SLOT, ART_SLOT) to clip back down.
_glitch_corner_ticks had the same missing-size issue (no explicit box
constraining it to the art slot at all), which is why the right-side
ticks were landing at the far edge of the full 64px canvas instead of the
32px art box's actual right edge.
Also fixed the ticks all being identical L-shapes (opening down-right)
regardless of which corner they were in, so only the true top-left one
looked correct. Each tick's two line segments are now explicitly pushed
to the correct edge of its own small box via Padding, so all four open
toward the image's center as intended.
Rebuilt the track-transition "static overlay" as a chromatic-aberration
glitch on the *live* album art instead of a bundled, pre-baked animated
GIF: two duplicate, differently-tinted copies of the actual art (via
filter.Hue, confirmed real and working), each cropped to a shifting
horizontal band, plus scanlines and viewfinder-style corner tick marks.
Loosely modeled on SUB/WAVE's own web UI, which does something similar to
its cover art on track change (found via its compiled CSS/JS: a
.v3-cover-live class gating an RGB-split band glitch, sweeping scanlines,
and corner tick marks). The exact keyframe timing past the first step
wasn't visible in the minified output, so GLITCH_STATES is an original
jitter sequence in the same spirit, not a reproduction of unseen code.
This is the third design for this effect: sparse noise specks, then a
version derived from a real "VHS glitch" stock-footage clip (dropped
because recognizable footage content showed through at 32x32, and it
compressed worse than procedural noise), now this. Since it needs no
bundled asset, the app goes back to a single-file upload -- reverted the
Deploy section accordingly.
Verified: renders cleanly across all zoom-level/static-overlay
combinations, payload stays well under the 460KB limit, and
render.Sequence's lack of looping (confirmed in pixlet's own source)
means the RGB-split jitter plays once per show_static window and settles
to just scanlines/ticks over clean art for the remainder, not a
constant distraction for the whole window.
Replaced the sparse-noise-specks static effect with a jagged horizontal
glitch band (plus scattered specks), still procedurally generated with
Pillow -- no source footage involved. Tried deriving this look from a
real, explicitly free-to-use "VHS glitch" stock clip first, but dropped
that approach: even downsampled to 32x32 and thresholded for
transparency, recognizable footage content showed through at any opacity
that read as an actual band rather than noise, and it compressed
noticeably worse than procedural noise. Verified payload stays well
under the 460KB limit against the live station.
Since the static-overlay feature bundled images/static.gif, the app needs
its asset uploaded alongside the .star file. A single-file upload (the
old instructions) will fail to resolve the load("images/static.gif", ...)
path. Updated Deploy section to match ormakers-tronbyt's zip-upload
instructions; also gitignored *.zip as a build artifact.
Shows a brief static effect (sparse noise specks, bundled as a small
animated GIF) over the album art during the first/last 5 seconds of a
track, as a transition cue. Implemented via render.Stack compositing a
bundled images/static.gif over the zoomed art -- pixlet has no per-frame
procedural drawing hook, so the noise is pre-baked with Pillow rather
than generated at render time; render.Image plays animated GIFs back
natively, independent of the zoom's own animation timeline.
Tuned the GIF's frame count/density/flicker speed down after an initial
version pushed total payload within a few percent of the 460KB device
limit on some album art -- more, faster-changing frames give WebP less
opportunity to dedupe near-identical frames, which compounds with the
zoom animation's own dedupe needs. Final version adds ~50KB regardless of
art complexity, verified via a pinned-cover test harness for reliable
before/after comparison (the live station's currently-playing track kept
changing between test runs otherwise).
Added a "Static overlay" On/Off schema toggle (default On) per request.
Random zoom position was previously picked once per track (stable via
subsonic_id) and held for that track's entire runtime. Changed it to
rotate on a ~10s cadence based on wall-clock time, still offset by a
per-track hash so different tracks don't all land on the same spot in
the same window. This lets a single long track show off different
sections of its own artwork over time, rather than being stuck on one
spot until the next track starts.
The previous "fix" for Scale's crop bias paired Scale with a computed
Translate, on the mistaken assumption that Scale's default pivot is the
top-left corner. It isn't: pixlet's 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 meant to fix.
animation.Origin does directly and correctly what that Translate was
clumsily approximating, so the Translate (and its rounding="none"
workaround) is removed entirely in favor of Origin. This also means
"strong" no longer needs the 1.45 safety cap -- restored to 1.7, since
the black-border risk was specific to the Translate formula, not Scale +
Origin (verified via the calibration tool at several off-center origins).
Added a "Zoom position" schema option: Center (default) or Random. Random
picks from a handful of preset off-center 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 refresh.
Verified: no black border, no popping (confirmed via frame-delta
analysis -- smooth throughout, no Translate means rounding is moot), and
payload well under the 460KB limit at every zoom level/position
combination, against the live station.
Root-caused the "pop" from the Scale+Translate centered zoom (reverted a
few commits back) to pixlet's own source: render/animation/translate.go
rounds a Translate's x/y to the nearest whole pixel every frame by
default (rounding = "round"), while animation/scale.go never rounds
Scale. A continuously-interpolating Scale combined with a rounded
Translate holds still for several frames, then jumps a whole pixel at
once -- a real, confirmed pixlet behavior, not a flaw in this app's
formula.
Transformation accepts a `rounding` parameter; passing "none" disables
the snapping. Confirmed via frame-by-frame pixel-delta analysis: the
per-frame change is now even throughout the animation (consistently
~900 AE out of 1024 possible, every frame) instead of long near-zero
stretches punctuated by sudden full-frame jumps.
Re-enables the centered zoom (Scale + Translate) that was reverted for
jerkiness, now smooth. ZOOM_LEVELS["strong"] stays capped at 1.45 --
that black-border ceiling is unrelated to rounding and still applies.
Verified against the live station: no pop, no black border at any zoom
level, payload ~370KB (up from ~220-320KB, since rounding="none" means
WebP can dedupe fewer near-identical frames) but still well under the
460KB device limit.
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.
Dividing the centering shift by top_scale (previous commit) avoided the
strong-zoom black border, but made the shift a nonlinear function of the
scale value being interpolated -- mid-animation, the pan no longer
tracked the zoom at a constant rate, which read as jerky/skippy motion.
Reverted shift to the original linear formula, (1-s)*(ART_SIZE/2), which
moves smoothly in lockstep with Scale's own eased interpolation. Instead,
capped ZOOM_LEVELS["strong"] at 1.45 (down from 1.7) -- the highest value
where this plain linear formula stays safely inside the art's bounds,
verified with the calibration tool (no black border across the full
animation cycle, at both 1.45x and against a live station).
The centering Translate formula, (1-s)*(ART_SIZE/2), was derived assuming
it's applied in final output pixels. That held up at a moderate zoom
(1.4x) but pushed past the image's actual edge at a strong one (1.7x) --
a real, on-device black border in the bottom-right corner, confirmed with
the calibration tool (true black RGB(0,0,0) pixels, not just dark
content).
Dividing the shift by the scale factor keeps the crop inside the image's
bounds at every zoom level the app offers, verified via the calibration
tool at 1.0/1.15/1.4/1.7x and against a live station -- at the cost of
the most extreme (1.7x) setting being very slightly off-center rather
than perfectly centered, which is a trade worth making since going
outside the art's bounds is worse. Moderate and subtle remain visibly
centered.
dev/calibration.png is a synthetic grid (concentric color rings, corner
markers, a crosshair) and dev/calibration_test.star runs it through the
same zoom logic as the real _art_widget, so cropping can be checked
visually without depending on what a live station happens to be playing.
This is what was used to find and verify the centered-zoom fix.
animation.Scale's pivot is fixed at the top-left corner with no way to
change it (an explicit origin param has no effect at all). Left alone,
that meant zooming in cropped progressively more off the bottom and right
than the top and left -- and since most of the animation's duration is
spent between keyframes rather than sitting at the symmetric rest state,
this read as a persistent "bottom and right are cropped" on the real
device.
Fix: pair Scale with a Translate that's a deterministic function of the
same scale value, re-centering the zoom so it crops evenly from all
sides. Verified against a synthetic calibration grid image (concentric
color rings + corner markers) rendered through the actual _art_widget
code -- confirms even margins at rest and symmetric cropping as it zooms
in, with no black border anywhere in the cycle. Safe because scale never
drops below 1.0 here; the earlier black-border bug came from combining
Translate with scale below 1.0, not from Translate itself.
zoom_level=off skipped animation.Transformation entirely, rendering the
art as a bare Image in a Box. Every other zoom level wrapped the same
Image in a Transformation. Empirically, that wrapper shifts the crop by a
couple pixels versus the bare Image, even at an explicit identity
Scale(1.0, 1.0) keyframe -- so "off" and the rest state of every other
zoom level didn't actually agree on framing, despite both nominally being
scale 1.0. That's what looked like the zoom always starting "a little
zoomed in" relative to off.
Fix: always go through the same Transformation, using a degenerate
animation (identical Scale(1.0, 1.0) keyframes, no visible movement) for
zoom_level=off. Verified pixel-identical (AE=0) first frames across off/
subtle/moderate/strong against the same live track.
Previously these rows were only added when the underlying data was
present, so the layout would shift depending on what SUB/WAVE happened to
report at the moment -- e.g. the time row would jump up a line whenever
there was no active show. Now both rows always render (with blank text
where needed) so they hold a fixed vertical position regardless of what
data is available.
Zoom level is a dropdown (Off/Subtle/Moderate/Strong) controlling how far
the album art zooms in during its breathing effect; Off skips the
animation.Transformation entirely and just shows a static crop.
Color schemes now match SUB/WAVE's own web UI theme picker (Blueprint,
Classic Dark, Classic Light, Cyberpunk, Flare, Recon, Signal, Vinyl) so a
station can mirror its own site theme on the device, including a
background fill that was previously hardcoded to black.
The rest state used Scale(ART_SLOT/ART_SIZE, ...) (~0.71) on a 45x45
image to shrink it down and reveal the whole picture, but scaling below
1.0 turns out to be unsafe: empirically it reintroduces the black-border
bug well before the math would predict any gap (a visible gap opens up
below roughly 0.9-0.95, not just when the scaled image shrinks smaller
than the display slot). So the whole "shrink down from oversized to
reveal everything" design doesn't work.
Instead, keep the oversized render just barely larger (36x36 vs. the
32x32 slot) so the safe floor of scale = 1.0 already shows ~89% of the
image -- close enough to read as "fully zoomed out" -- while still
leaving headroom above 1.0 for a real zoom-in effect.
Swapped the keyframes so scale=1.0 (full frame) is the loop's start/end
state and the zoom-in happens at the midpoint, matching how it's meant to
read: settle on the full picture, breathe in, breathe back out.
At the old 60s round-trip, a fresh render (triggered by the server well
before a minute passes) kept restarting the animation from tick 0, so only
the outbound zoom-out was ever visible on-device -- it never appeared to
zoom back in. Shortening the loop to 10s total makes it far more likely a
full out-and-back cycle plays before the next refresh.
The corner-reveal zoom scaled down from 1.0 toward ~0.71 (via Translate
to shift toward a corner), which showed up on a real device as a black
border cutting into the art. Empirical testing against the actual Row
layout at native canvas size showed the outer Box auto-fits an oversized
child at Scale(1.0, 1.0), so scaling down from there shrinks the already-
fitted image further and exposes background outside its bounds. Scaling
up only ever crops further into the image, which is always safe.
This drops Translate and the per-track corner selection (animation.Scale
pivots at the top-left corner by default with no way to change it -- an
explicit origin parameter was tried and had no effect), so the effect now
always zooms into the top-left corner instead of a track-dependent one.