The row had nothing constraining its width -- every other line in this
column is a Marquee, which enforces its own declared width, but this
plain Row had no such limit, so any estimation error in the
character-count-based width guess (pixlet has no way to measure
rendered text width from Starlark) silently overflowed the column
instead of being clipped. Wrapped in a Box(width=text_width, height=6)
to hard-clip it.
The height was not optional: a bare Box(width=...) with no height
"expand[s] to fill all available space" (pixlet's own docs), and inside
this Column's main_align="space_between", that meant the row silently
grabbed nearly all remaining height, squeezing the progress bar below
it out of view -- same bug class as the earlier time_temp centering
fix (a flexible/expanding child stealing vertical space), just via
Box's implicit expand-when-unconstrained behavior this time instead of
Row's explicit expanded=True. Caught by tracing pixel colors down the
render rather than trusting a glance: the row's text was present, just
not where expected, and the progress bar's color was missing from the
frame entirely.
Verified two-digit minutes work correctly: a minimal standalone test
replicating this exact construction with a hardcoded "12:34" renders
right-aligned with no overflow, and the real (unmodified) app was
independently re-verified live against two different real tracks,
confirming the fix holds with genuine data, not just a synthetic case.
New track_length ("M:SS" from duration) and bpm ("N bpm", rounded)
formatted fields on the now-playing fetch. 2x only, tied to the
progress bar's own presence (both come from data that's only
meaningful on a long-lived server -- see _track_progress's own
comment), since the row is meant to sit directly above it rather than
float with nothing below it.
Right-aligns track_length via a computed left Padding/spacer Box (same
estimated-width approach already used for time_temp's centering), not
render.Row(expanded=True, main_align="space_between") -- that pattern
already caused a real bug once this session (the time_temp centering
fix): `expanded` on a Row grabs extra *vertical* space from the
enclosing Column (main_align="space_between"), pushing the progress
bar out of view. Caught this before it shipped by pixel-sampling the
bottom rows and confirming the progress bar's exact "bar_empty" color
is still present where expected, not just eyeballing the render.
Verified: renders between time_temp and the progress bar at 2x ("136
bpm" / "6:45"), 1x unaffected (no new row, unchanged layout).
Re-verified against the same real "NOW"-style compilation cover used to
tune the previous reduction: "moderate" at 1.2 measures ~309-315KB
across several runs, comparable to the prior 1.25 value (~297-316KB) --
the remaining payload variance at this point is mostly the glitch
segment's own per-render randomness, not the small zoom delta.
Crop-centering safety re-checked with the calibration tool (not real
album art, which can have genuinely black regions a naive corner-pixel
check can't tell apart from an actual exposed margin -- hit exactly
that false positive first, using the real compilation cover, before
switching to the synthetic calibration grid) at the new "strong" value
(1.35) across every ZOOM_RANDOM_SPOTS position: 0 black/void corner
hits.
Reports of an overrun at "moderate" zoom on a busy "NOW"-style
compilation cover (lots of artist names/logos -- high-detail, not
smooth/flat art). Confirmed with a real compilation cover (downloaded
via iTunes' search API, close enough in style to the reported one):
raw zoom_scale correlates with payload for busy art, and the original
"moderate" (1.4) measured ~329-336KB across repeated runs.
ZOOM_LEVELS split into ZOOM_LEVELS_1X (reduced: subtle 1.15->1.1,
moderate 1.4->1.25, strong 1.7->1.45) and ZOOM_LEVELS_2X (unchanged) --
1x only, per explicit request, since 2x has more buffer headroom on
that hardware (established in the earlier 2x-support work). main()
picks the right dict from the same `scale` value already threaded
everywhere else.
Verified: the reduced "moderate" (1.25) against the same busy cover
measured ~297-316KB across repeated runs, a real, repeatable margin
gain (not just run-to-run noise from the glitch segment's own
randomness). 0 black-corner hits at the new 1x "strong" (1.45) +
random zoom position, the established worst-case combination.
Reports of some rendered segments coming out too large for the physical
Gen1 device, suspected (correctly) to be complex/busy album art pushing
the glitch segment's payload up. Confirmed with synthetic adversarial
testing: colored noise art (sharp per-pixel detail surviving the
downscale to decode resolution, not smoothed-away high-res noise) at
the full 22s window + strong zoom + random position measured 354KB --
uncomfortably close to the Gen1 460KB limit. Isolating glitch from
baseline on the same noisy art (216KB without glitch, 354KB with)
confirmed the glitch segment itself as the primary driver, exactly as
suspected: it builds many genuinely distinct frames (jitter shifts,
hue-tinted bands, scanline sweep) from the live art, and busy/high-
entropy art means those frames compress far worse than against typical
album art.
GLITCH_ACTIVE_SECONDS cut from the full STATIC_OVERLAY_END_SECONDS (22)
to a flat 18 -- re-tested against the same adversarial noise art: 333KB,
widening the safety margin from 106KB (23%) to 127KB (28%). Re-accepts
the tradeoff this was widened past once before (jitter/bands/scanlines
go static for the last few seconds of a very-long "ending" window)
deliberately this time, for the payload headroom.
Verified: normal live-art render still well under budget (345KB), 0
black-corner hits in isolation at worst-case settings -- the duration
change doesn't touch any of the margin-safety mechanics, just how many
cycles play out.
Replaces the id-based theme lookup (/api/state's theme.active mapped to
this app's own hardcoded COLOR_SCHEMES) with the real, currently-in-effect
token values from /api/themes. SUB/WAVE themes aren't a fixed set of 8
presets -- an operator can add their own theme JSON with arbitrary token
values (controller/src/themes.ts loads built-ins plus optional files from
${STATE_DIR}/themes/), so a theme id like "cyberpunk" on some station
was never guaranteed to match this app's own guess at its hex values.
Also fixes the precedence question directly: /api/themes exposes
activeSource ("show" or "station", per controller's own
theme-provenance.ts), so the override only applies when a show has
genuinely pinned its own theme -- when it's "station" (no show-level
pin), the user's own configured color_scheme is used, not the station's
default, which is what was actually wanted.
Token values aren't all plain hex, though: surveyed every token across
every theme on the live test station and found three formats in use --
"#rrggbb", "oklch(L C H)", and "color-mix(in oklab, COLOR1 P1%, COLOR2)"
(COLOR1/COLOR2 themselves hex or oklch, sometimes nested). Implements
the standard CSS Color 4 / Bjorn Ottosson OKLab conversion to handle all
three. Verified three ways before trusting it: hex -> OKLab -> hex
round-trips to the exact original hex; oklch(L C H) for pure red/green/
blue/white matches their well-known reference hex values exactly; and
the color-mix outputs were cross-checked against an independent Python
implementation of the same formulas and matched exactly.
Token-to-role mapping: background/text/accent/meta/separator/bar_empty
<- --bg/--ink/--accent/--muted/--line/--surface, confirmed present on
every theme on the live station. --line is usually a translucent rgba
in SUB/WAVE's own themes -- preserved as an 8-digit #rrggbbaa hex
(alpha carried through the OKLab math too) rather than flattened to
opaque, matching the alpha-hex format already used elsewhere in this
file (e.g. the corner ticks' "#00000080").
Verified end-to-end: a test render with the real "blueprint" theme's
tokens forced through activeSource="show" produced the exact expected
background pixel (10,58,146 = #0a3a92); the live station (currently no
show-level override, activeSource="station") correctly falls back to
an explicitly configured color_scheme=vinyl instead of being overridden.
New "album_year" field on the now-playing fetch, formatted as
"Album · Year" (either half omitted if absent). 2x only, same reasoning
as the show/host split and the time/temp de-marquee -- no spare row for
it at 1x's tighter layout.
Marquee, not a centered static line like the DJ name/time_temp: album
titles can run as long as track titles, so there's no "assumed short
enough to just fit" case here the way there was for those.
Verified: renders between artist and the separator, no vertical
overflow: title/artist/album/separator/show/host/time all fit within
the available height with the progress bar still visible below. 1x
unaffected (no album line, unchanged layout).
SUB/WAVE lets a show set its own color theme, which overrides the
station's default while that show is on air -- the station's own web UI
already does this (controller/src/routes/public.ts resolves it
server-side: activeShow?.themeId || station default). That resolved
value rides on /api/state's theme.active field, not /api/now-playing,
so this adds a second fetch (_fetch_show_theme) rather than reading an
existing response field.
Theme ids there use hyphens ("classic-light"); this app's COLOR_SCHEMES
keys use underscores ("classic_light") -- otherwise the same 8 names,
confirmed against SUB/WAVE's own theme registry (press-run-plates.ts),
so swapping the separator is enough to look it up directly.
Falls back to the user's configured color_scheme on a fetch failure, a
non-200 response, or an id this app doesn't recognize -- verified a
real reachable server returns a clean 404 for an unsupported path
(not a hard connection error), so that fallback path is exercised by
real servers, not just a theoretical case.
Verified against the live test station (currently "classic-light"):
renders with its light cream background/dark text/red accent instead
of the hardcoded cyberpunk default, confirming the override actually
takes effect.
Show names can run up to ~60 characters -- nowhere close to fitting
even at 2x's doubled width, so that line stays a Marquee regardless.
DJ/host names are assumed short enough to just fit without scrolling,
so that line is centered static text instead, using the same
codepoints()-based length check and Marquee fallback already
established for time_temp (a bare len() would overcount any multi-byte
character in the name).
2x only, per explicit scope check -- splitting into two rows needs a
spare vertical row that doesn't exist at 1x's tighter layout; 1x keeps
the existing single combined show+host Marquee line unchanged.
An empty-host placeholder box keeps the row position fixed when no DJ
is credited, matching the existing "always render both rows, even
empty" rationale for show/host already noted above. Verified against
live data (activeShow currently null): empty show line, host line
("Marlowe") correctly centered on its own row, no vertical overflow.
terminus-16 is exactly double tb-8's height (8px -> 16px), and also
pixlet's own documented default font for 2x displays -- a natural
choice for making the title genuinely bigger rather than just higher-
resolution. Title only, per request; artist/show/host/time stay at
their existing fonts. Applied to both the with-art and no-art-fallback
layouts for consistency, since it's the same title line either way.
Verified at 2x: title reads clearly larger, rest of the column (artist,
show/host, time/temp, progress bar) still fits within the available
height with room to spare. 1x unaffected (still tb-8).
render.Row(expanded=True, main_align="center") correctly centered the
time/temp text horizontally, but `expanded` also grabbed extra
*vertical* space from the enclosing Column (main_align="space_between"),
which pushed the progress bar further down than intended.
Replaced with a computed left Padding instead -- only nudges this one
line horizontally, so it can't affect the Column's vertical
distribution the way a flexible/expanding child can. tom-thumb is
variable-width, so the padding amount is an estimate (~3.9px/char,
measured via the CLI against several real time/temp strings) rather
than pixel-exact, but close enough not to look off-center for strings
in the length range this branch allows.
Separately verified (pixel-sampled, not just eyeballed) that the
progress bar was never actually missing -- confirmed present with the
exact "bar_empty" color at every recent commit, including before this
fix. It's just low-contrast at 0% progress (dark purple on near-black),
which is what a one-shot `pixlet render` always shows (empty cache ->
elapsed=0 -> fraction=0.0), not a regression.
Centering itself was straightforward (Box + Row main_align="center"),
but it silently wasn't firing: the length check gating the centered/
static presentation used a bare len(now["time_temp"]), and Starlark's
len() on a string counts UTF-8 *bytes*, not characters. This string
almost always has a multi-byte character (the middle-dot separator, the
degree sign) -- confirmed len() reports 16 for a real 14-character
string like "2:45 pm · 63°F", pushing it over the 15 threshold and
silently routing it to the Marquee branch instead. Visually that looked
like "the centering isn't working" (Marquee shows already-fitting
content statically, left-aligned, instead of scrolling it) rather than
an obvious bug.
Fixed by counting via len(list(now["time_temp"].codepoints())) instead
-- confirmed 14 for the same string. Verified centered to within 1px
(4px left margin, 5px right, as close as integer pixels allow) once the
length check was actually passing.
First experiment in using 2x's extra width for something other than
just stretching the 1x layout: the time/temp line usually fits without
scrolling once the text column doubles in width. Marquee is unneeded
overhead for short, non-truncated text.
Guarded by a length check (<=15 chars), not just scale==2: pixlet has
no way to measure rendered text width from within Starlark, and
tom-thumb is variable-width, so this is a heuristic rather than an
exact fit check. Measured via the CLI: a realistic worst case like
"12:59 pm · 100°F" (16 chars -- 2-digit hour, 3-digit temp) renders at
63px against a 64px budget, just 1px of margin -- too close to rely on.
15 chars keeps a real margin (59px measured) against overflowing into
the art column. Falls back to Marquee past that length, and always at
1x regardless of length (tighter width there either way).
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.
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.