diff --git a/README.md b/README.md index dffc073..887b6ca 100644 --- a/README.md +++ b/README.md @@ -31,8 +31,8 @@ app's config when adding it to a device. to show off different sections of its art over time instead of being stuck on one spot for its whole runtime. - **Static overlay** — On (default) or Off. When on, shows a brief - TV-glitch effect (a jagged horizontal static band, plus scattered noise - specks) over the album art during the first/last few seconds of a + chromatic-aberration glitch (RGB-split bands, scanlines, and corner tick + marks) over the album art during the first/last few seconds of a track, as a transition cue. ## How it works @@ -116,33 +116,39 @@ app's config when adding it to a device. its full module list) — so there's no way to pre-bake or crop-at-render- time from a source we don't have until the request hits the live station. -- When enabled (Static overlay config), shows a brief TV-glitch effect - over the album art during the first/last `STATIC_OVERLAY_SECONDS` (5s) - of a track, as a transition cue. Implemented as a small bundled animated - GIF (`images/static.gif` — a jagged horizontal static band plus - scattered noise specks, over a transparent background, procedurally - generated with Pillow, not sourced from any existing footage) - composited over the zoomed art via `render.Stack`, not generated at - render time: pixlet's Starlark sandbox has no per-frame procedural - drawing hook, and `animation.Transformation` only interpolates between - fixed keyframes, so there's no way to get genuine random noise out of - it. `render.Image` plays back animated GIFs natively, looping - independently of the zoom's own animation timeline. An earlier version - tried deriving this effect from a real "VHS glitch" stock-footage clip - (downloaded, downsampled, and thresholded for transparency) instead of - generating it — dropped because, even licensed as free-to-use, cropping +- When enabled (Static overlay config), shows a brief chromatic-aberration + glitch over the album art during the first/last `STATIC_OVERLAY_SECONDS` + (5s) of a track, as a transition cue: two duplicate, differently-tinted + copies of the *actual* art, each cropped to a shifting horizontal band + (RGB-split look), 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 — not + published anywhere as a spec, and the exact keyframe timing past the + first step wasn't visible in the minified output, so the specific + jitter sequence here, `GLITCH_STATES`, is an original sequence in the + same spirit rather than a reproduction of unseen code). + + Built from the live, already-zoomed art via `filter.Hue`, not a bundled + asset — pixlet's `filter` module can recolor an arbitrary widget, so the + glitch bands reflect whatever's actually on screen. The RGB-split jitter + plays once per `show_static` window via `render.Sequence` (which doesn't + loop — confirmed in pixlet's own source, it just stops painting once its + total frame count is used up) and then goes quiet, while the scanlines + and corner ticks persist for the whole window as a constant texture. + + Went through two earlier, discarded designs before landing here, both + bundled pre-baked animated GIFs rather than working from the live art: + first sparse noise specks, then a version derived from a real "VHS + glitch" stock-footage clip (downloaded, downsampled, and thresholded for + transparency) — dropped because, even licensed as free-to-use, cropping it down to 32x32 let recognizable footage content show through at any - opacity that read as an actual "band" rather than sparse noise, and it - compressed noticeably worse than procedural noise does. Like the - progress bar, this only works correctly on a long-lived server - with a persistent cache — a one-shot `pixlet render` CLI call always - sees `elapsed=0`, so it'll always show the "starting" static, never the - "ending" one. Tuned the GIF's frame count/density/flicker speed down - (fewer, sparser, slower-changing frames) after an initial version came - within a few percent of the 460KB device limit on some album art — more - distinct frames changing more often give WebP less opportunity to - dedupe near-identical frames, and that cost compounds with the zoom - animation already using up dedupe headroom of its own. + opacity that read as an actual "band" rather than noise, and it + compressed noticeably worse than procedural noise. The current + live-`filter.Hue` version needs no bundled asset at all, so this app is + back to a single-file upload. Like the progress bar, this only works + correctly on a long-lived server with a persistent cache — a one-shot + `pixlet render` CLI call always sees `elapsed=0`, so it'll always show + the "starting" static, never the "ending" one. - Shows the current show name (`context.activeShow.name`) and host name (`dj.name`) below the artist, separated by a thin divider line, and the local time/temperature (`context.clock`/`context.weather`) below that — @@ -215,12 +221,10 @@ ever changes. ## Deploy -On the Tronbyt server, go to the device's **Add App** page → **Upload app**, -and upload a zip of this repo's contents (`subwave_nowplaying.star` + -`images/`, zipped at the root, no extra parent folder) — it bundles -`images/static.gif` for the static-overlay effect, so a single-file upload -of just the `.star` file will fail. Configure the Station URL field when -adding it to your device's schedule. +Upload `subwave_nowplaying.star` directly through your Tronbyt server's +"Upload app" button on a device's Add App page (single file, no bundled +assets — no need to zip). Configure the Station URL field when adding it to +your device's schedule. ``` zip -r subwave-nowplaying.zip subwave_nowplaying.star images/ diff --git a/images/static.gif b/images/static.gif deleted file mode 100644 index c36ed98..0000000 Binary files a/images/static.gif and /dev/null differ diff --git a/subwave_nowplaying.star b/subwave_nowplaying.star index 4e3739e..ea9f3ec 100644 --- a/subwave_nowplaying.star +++ b/subwave_nowplaying.star @@ -11,7 +11,7 @@ load("http.star", "http") load("cache.star", "cache") load("time.star", "time") load("animation.star", "animation") -load("images/static.gif", STATIC_ASSET = "file") +load("filter.star", "filter") DEFAULT_STATION_URL = "" NOWPLAYING_TTL = 20 @@ -274,6 +274,14 @@ 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): + cropped = _zoomed_art(art_bytes, zoom_scale, zoom_position, subsonic_id) + + if not show_static: + return cropped + + return render.Stack(children = [cropped, _glitch_effect(cropped)]) + +def _zoomed_art(art_bytes, zoom_scale, zoom_position, subsonic_id): art = render.Image(src = art_bytes, width = ART_SIZE, height = ART_SIZE) # Always route through animation.Transformation, even when zoom is off @@ -320,21 +328,109 @@ def _art_widget(art_bytes, zoom_scale, zoom_position, subsonic_id, show_static): ), ], ) - cropped = render.Box(width = ART_SLOT, height = ART_SLOT, child = zoom) + return render.Box(width = ART_SLOT, height = ART_SLOT, child = zoom) - if not show_static: - return cropped +# Modeled on SUB/WAVE's own web UI, which applies a track-transition +# effect to the cover art (found via its compiled CSS/JS, not written down +# anywhere as a spec): an RGB-split/chromatic-aberration glitch -- two +# duplicate, differently-tinted copies of the art, each cropped to a +# shifting horizontal band -- plus scanlines and viewfinder-style corner +# tick marks. Built here from scratch against the *live* art via +# filter.Hue, not a bundled asset -- pixlet's `filter` module (confirmed +# real and working, same as `animation.Origin`) can recolor an arbitrary +# widget, including an already-zoomed render.Image, so the glitch bands +# reflect whatever's actually on screen rather than a generic texture. +# This replaced an earlier version built as a bundled static.gif overlay +# (first sparse noise specks, briefly a VHS-footage-derived band) -- that +# approach is still viable for projects without filter.star, but once a +# live-recolor option exists it's a better match for what SUB/WAVE itself +# does, and it means this app goes back to a single-file upload (no +# bundled asset to zip alongside it). +# +# The exact keyframe sequence/timing SUB/WAVE's CSS uses past the first +# keyframe isn't visible in its compiled output (no source maps), so +# GLITCH_STATES below is an original sequence in the same spirit (shifting +# inset bands, step-end/snap timing via a Sequence rather than eased +# motion) rather than a reproduction of unseen code. Band proportions +# (top/height as a fraction of ART_SLOT) are loosely modeled on the one +# keyframe that was visible: roughly the top third and an overlapping +# middle band, each about 30% of the frame's height. +GLITCH_HOLD_TICKS = 5 # ~250ms per jitter state at the default 50ms/tick +GLITCH_STATES = [ + # (top1, height1, shift1, hue1, top2, height2, shift2, hue2) + (3, 9, -2, 150.0, 19, 8, 2, -60.0), + (2, 8, 2, 150.0, 20, 9, -3, -60.0), + (5, 7, -3, 150.0, 17, 10, 1, -60.0), + (1, 10, 1, 150.0, 21, 7, -2, -60.0), +] - # images/static.gif is a bundled, pre-baked animated GIF (sparse noise - # specks over a transparent background) -- not something generated at - # render time. pixlet's Starlark sandbox has no per-frame procedural - # drawing hook; animated widgets work by either interpolating - # animation.Transformation keyframes (no good way to get true random - # noise out of that) or, as here, by playing back a real animated - # image asset, which render.Image supports natively for GIFs. The GIF - # loops independently of ART_ZOOM_DURATION at its own frame rate. - static = render.Image(src = STATIC_ASSET.readall(), width = ART_SLOT, height = ART_SLOT) - return render.Stack(children = [cropped, static]) +def _glitch_band(zoomed_art, top, height, shift_x, hue_change): + tinted = filter.Hue(child = zoomed_art, 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), + ) + return render.Padding(pad = (max(0, shift_x), 0, max(0, -shift_x), 0), child = band) + +def _glitch_jitter(zoomed_art): + states = [] + for top1, h1, shift1, hue1, top2, h2, shift2, hue2 in GLITCH_STATES: + state = render.Stack(children = [ + _glitch_band(zoomed_art, top1, h1, shift1, hue1), + _glitch_band(zoomed_art, top2, h2, shift2, hue2), + ]) + + # Wrapping in a no-op Transformation just to pin this state's + # FrameCount to GLITCH_HOLD_TICKS, so render.Sequence holds on it + # for that many ticks before advancing to the next state -- + # Sequence has no other way to control how long a child plays. + states.append(animation.Transformation( + child = state, + duration = GLITCH_HOLD_TICKS, + keyframes = [ + animation.Keyframe(percentage = 0.0, transforms = []), + animation.Keyframe(percentage = 1.0, transforms = []), + ], + )) + return render.Sequence(children = states) + +def _glitch_scanlines(): + # render.Sequence doesn't loop (it just stops painting once its total + # frame count is exhausted -- confirmed in pixlet's own source), so + # the RGB-split jitter above plays once per show_static window and + # then goes quiet; these persist for the whole window as a constant + # texture rather than a one-shot burst, closer to how SUB/WAVE's + # scanlines sweep continuously while its live class is applied. + lines = [] + for y in range(ART_SLOT): + if y % 4 == 0: + lines.append(render.Box(width = ART_SLOT, height = 1, color = "#00000080")) + else: + lines.append(render.Box(width = ART_SLOT, height = 1, color = "#00000000")) + return render.Column(children = lines) + +def _glitch_corner_ticks(color): + def tick(): + return render.Stack(children = [ + render.Box(width = 5, height = 1, color = color), + render.Box(width = 1, height = 5, color = color), + ]) + + return render.Column( + main_align = "space_between", + expanded = True, + children = [ + render.Row(main_align = "space_between", expanded = True, children = [tick(), tick()]), + render.Row(main_align = "space_between", expanded = True, children = [tick(), tick()]), + ], + ) + +def _glitch_effect(zoomed_art): + return render.Stack(children = [ + _glitch_jitter(zoomed_art), + _glitch_scanlines(), + _glitch_corner_ticks("#33ff99"), + ]) def _message_screen(lines, colors): return render.Column(