Replace bundled static.gif with a live RGB-split glitch effect
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.
This commit is contained in:
1 parent
8e101b997e
commit
2993e9e9a9
3 files changed
+148
-48
No files matched your search
@@ -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
|
to show off different sections of its art over time instead of being
|
||||||
stuck on one spot for its whole runtime.
|
stuck on one spot for its whole runtime.
|
||||||
- **Static overlay** — On (default) or Off. When on, shows a brief
|
- **Static overlay** — On (default) or Off. When on, shows a brief
|
||||||
TV-glitch effect (a jagged horizontal static band, plus scattered noise
|
chromatic-aberration glitch (RGB-split bands, scanlines, and corner tick
|
||||||
specks) over the album art during the first/last few seconds of a
|
marks) over the album art during the first/last few seconds of a
|
||||||
track, as a transition cue.
|
track, as a transition cue.
|
||||||
|
|
||||||
## How it works
|
## 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-
|
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
|
time from a source we don't have until the request hits the live
|
||||||
station.
|
station.
|
||||||
- When enabled (Static overlay config), shows a brief TV-glitch effect
|
- When enabled (Static overlay config), shows a brief chromatic-aberration
|
||||||
over the album art during the first/last `STATIC_OVERLAY_SECONDS` (5s)
|
glitch over the album art during the first/last `STATIC_OVERLAY_SECONDS`
|
||||||
of a track, as a transition cue. Implemented as a small bundled animated
|
(5s) of a track, as a transition cue: two duplicate, differently-tinted
|
||||||
GIF (`images/static.gif` — a jagged horizontal static band plus
|
copies of the *actual* art, each cropped to a shifting horizontal band
|
||||||
scattered noise specks, over a transparent background, procedurally
|
(RGB-split look), plus scanlines and viewfinder-style corner tick marks.
|
||||||
generated with Pillow, not sourced from any existing footage)
|
Loosely modeled on SUB/WAVE's own web UI, which does something similar
|
||||||
composited over the zoomed art via `render.Stack`, not generated at
|
to its cover art on track change (found via its compiled CSS/JS — not
|
||||||
render time: pixlet's Starlark sandbox has no per-frame procedural
|
published anywhere as a spec, and the exact keyframe timing past the
|
||||||
drawing hook, and `animation.Transformation` only interpolates between
|
first step wasn't visible in the minified output, so the specific
|
||||||
fixed keyframes, so there's no way to get genuine random noise out of
|
jitter sequence here, `GLITCH_STATES`, is an original sequence in the
|
||||||
it. `render.Image` plays back animated GIFs natively, looping
|
same spirit rather than a reproduction of unseen code).
|
||||||
independently of the zoom's own animation timeline. An earlier version
|
|
||||||
tried deriving this effect from a real "VHS glitch" stock-footage clip
|
Built from the live, already-zoomed art via `filter.Hue`, not a bundled
|
||||||
(downloaded, downsampled, and thresholded for transparency) instead of
|
asset — pixlet's `filter` module can recolor an arbitrary widget, so the
|
||||||
generating it — dropped because, even licensed as free-to-use, cropping
|
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
|
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
|
opacity that read as an actual "band" rather than noise, and it
|
||||||
compressed noticeably worse than procedural noise does. Like the
|
compressed noticeably worse than procedural noise. The current
|
||||||
progress bar, this only works correctly on a long-lived server
|
live-`filter.Hue` version needs no bundled asset at all, so this app is
|
||||||
with a persistent cache — a one-shot `pixlet render` CLI call always
|
back to a single-file upload. Like the progress bar, this only works
|
||||||
sees `elapsed=0`, so it'll always show the "starting" static, never the
|
correctly on a long-lived server with a persistent cache — a one-shot
|
||||||
"ending" one. Tuned the GIF's frame count/density/flicker speed down
|
`pixlet render` CLI call always sees `elapsed=0`, so it'll always show
|
||||||
(fewer, sparser, slower-changing frames) after an initial version came
|
the "starting" static, never the "ending" one.
|
||||||
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.
|
|
||||||
- Shows the current show name (`context.activeShow.name`) and host name
|
- Shows the current show name (`context.activeShow.name`) and host name
|
||||||
(`dj.name`) below the artist, separated by a thin divider line, and the
|
(`dj.name`) below the artist, separated by a thin divider line, and the
|
||||||
local time/temperature (`context.clock`/`context.weather`) below that —
|
local time/temperature (`context.clock`/`context.weather`) below that —
|
||||||
@@ -215,12 +221,10 @@ ever changes.
|
|||||||
|
|
||||||
## Deploy
|
## Deploy
|
||||||
|
|
||||||
On the Tronbyt server, go to the device's **Add App** page → **Upload app**,
|
Upload `subwave_nowplaying.star` directly through your Tronbyt server's
|
||||||
and upload a zip of this repo's contents (`subwave_nowplaying.star` +
|
"Upload app" button on a device's Add App page (single file, no bundled
|
||||||
`images/`, zipped at the root, no extra parent folder) — it bundles
|
assets — no need to zip). Configure the Station URL field when adding it to
|
||||||
`images/static.gif` for the static-overlay effect, so a single-file upload
|
your device's schedule.
|
||||||
of just the `.star` file will fail. Configure the Station URL field when
|
|
||||||
adding it to your device's schedule.
|
|
||||||
|
|
||||||
```
|
```
|
||||||
zip -r subwave-nowplaying.zip subwave_nowplaying.star images/
|
zip -r subwave-nowplaying.zip subwave_nowplaying.star images/
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 3.6 KiB |
+110
-14
@@ -11,7 +11,7 @@ load("http.star", "http")
|
|||||||
load("cache.star", "cache")
|
load("cache.star", "cache")
|
||||||
load("time.star", "time")
|
load("time.star", "time")
|
||||||
load("animation.star", "animation")
|
load("animation.star", "animation")
|
||||||
load("images/static.gif", STATIC_ASSET = "file")
|
load("filter.star", "filter")
|
||||||
|
|
||||||
DEFAULT_STATION_URL = ""
|
DEFAULT_STATION_URL = ""
|
||||||
NOWPLAYING_TTL = 20
|
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
|
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):
|
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)
|
art = render.Image(src = art_bytes, width = ART_SIZE, height = ART_SIZE)
|
||||||
|
|
||||||
# Always route through animation.Transformation, even when zoom is off
|
# 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:
|
# Modeled on SUB/WAVE's own web UI, which applies a track-transition
|
||||||
return cropped
|
# 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
|
def _glitch_band(zoomed_art, top, height, shift_x, hue_change):
|
||||||
# specks over a transparent background) -- not something generated at
|
tinted = filter.Hue(child = zoomed_art, change = hue_change)
|
||||||
# render time. pixlet's Starlark sandbox has no per-frame procedural
|
band = render.Padding(
|
||||||
# drawing hook; animated widgets work by either interpolating
|
pad = (0, top, 0, max(0, ART_SLOT - top - height)),
|
||||||
# animation.Transformation keyframes (no good way to get true random
|
child = render.Box(width = ART_SLOT, height = height, child = tinted),
|
||||||
# noise out of that) or, as here, by playing back a real animated
|
)
|
||||||
# image asset, which render.Image supports natively for GIFs. The GIF
|
return render.Padding(pad = (max(0, shift_x), 0, max(0, -shift_x), 0), child = band)
|
||||||
# loops independently of ART_ZOOM_DURATION at its own frame rate.
|
|
||||||
static = render.Image(src = STATIC_ASSET.readall(), width = ART_SLOT, height = ART_SLOT)
|
def _glitch_jitter(zoomed_art):
|
||||||
return render.Stack(children = [cropped, static])
|
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):
|
def _message_screen(lines, colors):
|
||||||
return render.Column(
|
return render.Column(
|
||||||
|
|||||||