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