mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 08:00:32 +02:00
Render complete Steam artwork for every sideload and fix VR shortcut identity
Add optional SteamGridDB settings, source-first fallbacks rendered with Steam canvas, backfill commands and shared APK/native library details. Verify live artwork and Open Saber Steam Play/Stop; document SuperTux's clipboard crash. Co-Authored-By: GPT-6 Astra <noreply@openai.com>
This commit is contained in:
1 parent
c06b3285f2
commit
f695f398de
18 files changed
+1097
-289
No files matched your search
+38
-86
@@ -1,8 +1,5 @@
|
||||
"""Small stdlib PNG compositor for Steam's Android library artwork."""
|
||||
import hashlib
|
||||
"""Bounded artwork inputs for Steam library canvas rendering."""
|
||||
import struct
|
||||
import textwrap
|
||||
import unicodedata
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import zlib
|
||||
@@ -12,18 +9,6 @@ SLOTS = {'grid': (600, 900), 'wide': (920, 430), 'hero': (3840, 1240),
|
||||
MAX_IMAGE = 12 * 1024 * 1024
|
||||
MAX_PIXELS = 8_000_000
|
||||
PNG = b'\x89PNG\r\n\x1a\n'
|
||||
# Five-column display lettering; labels remain exact in Steam's native text.
|
||||
GLYPHS = dict(zip('ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789- .?', (
|
||||
'0E11111F111111', '1E11111E11111E', '0F10101010100F', '1E11111111111E',
|
||||
'1F10101E10101F', '1F10101E101010', '0F10101711110F', '1111111F111111',
|
||||
'0E04040404040E', '0702020212120C', '11121418141211', '1010101010101F',
|
||||
'111B1515111111', '11191513111111', '0E11111111110E', '1E11111E101010',
|
||||
'0E11111115120D', '1E11111E141211', '0F10100E01011E', '1F040404040404',
|
||||
'1111111111110E', '11111111110A04', '11111115151B11', '11110A040A1111',
|
||||
'11110A04040404', '1F01020408101F', '0E11131519110E', '040C040404040E',
|
||||
'0E11010204081F', '1E01010E01011E', '02060A121F0202', '1F10101E01011E',
|
||||
'0E10101E11110E', '1F010204080808', '0E11110E11110E', '0E11110F01010E',
|
||||
'0000001F000000', '00000000000000', '00000000000C0C', '0E110102040004')))
|
||||
|
||||
|
||||
def chunk(kind, data):
|
||||
@@ -150,24 +135,6 @@ def image_type(data):
|
||||
raise ValueError('artwork must be a supported PNG or JPEG')
|
||||
|
||||
|
||||
def rectangle(pixels, width, x, y, w, h, color):
|
||||
row = bytes(color) * w
|
||||
for yy in range(y, y + h):
|
||||
start = (yy * width + x) * 4
|
||||
pixels[start:start + len(row)] = row
|
||||
|
||||
|
||||
def lettering(pixels, width, text, y, scale, color):
|
||||
x = (width - (len(text) * 6 - 1) * scale) // 2
|
||||
for ch in text:
|
||||
glyph = bytes.fromhex(GLYPHS.get(ch, GLYPHS['?']))
|
||||
for yy, bits in enumerate(glyph):
|
||||
for xx in range(5):
|
||||
if bits & (1 << (4 - xx)):
|
||||
rectangle(pixels, width, x + xx * scale, y + yy * scale, scale, scale, color)
|
||||
x += 6 * scale
|
||||
|
||||
|
||||
def stamp(pixels, width, icon, x, y, size):
|
||||
iw, ih, source = icon
|
||||
dw, dh = max(1, size * iw // max(iw, ih)), max(1, size * ih // max(iw, ih))
|
||||
@@ -182,60 +149,45 @@ def stamp(pixels, width, icon, x, y, size):
|
||||
pixels[dst + 3] = a + pixels[dst + 3] * (255 - a) // 255
|
||||
|
||||
|
||||
def fallback(label, icon_png=None):
|
||||
text = unicodedata.normalize('NFKD', label).encode('ascii', 'ignore').decode().upper().strip() or 'ANDROID APP'
|
||||
text = ' '.join(text.split())[:80]
|
||||
seed = hashlib.sha256(label.encode()).digest()
|
||||
accent = tuple(100 + c % 110 for c in seed[:3]) + (255,)
|
||||
try:
|
||||
icon = decode(icon_png)
|
||||
except (ValueError, zlib.error, struct.error):
|
||||
icon = None
|
||||
out = {}
|
||||
for slot, (w, h) in SLOTS.items():
|
||||
pixels = bytearray(w * h * 4)
|
||||
if slot != 'logo':
|
||||
for y in range(h):
|
||||
color = (14 + 12 * y // h, 22 + 16 * y // h, 34 + 22 * y // h, 255)
|
||||
rectangle(pixels, w, 0, y, w, 1, color)
|
||||
rectangle(pixels, w, w // 12, h - max(6, h // 70), w * 5 // 6, max(3, h // 180), accent)
|
||||
size = {'grid': 320, 'wide': 190, 'hero': 640, 'logo': 0, 'icon': 192}[slot]
|
||||
if size:
|
||||
x, y = (w - size) // 2, {'grid': 160, 'wide': 38, 'hero': 200, 'icon': 32}[slot]
|
||||
if icon:
|
||||
stamp(pixels, w, icon, x, y, size)
|
||||
else:
|
||||
scale = size // 9
|
||||
lettering(pixels, w, text[0], y + (size - scale * 7) // 2, scale, accent)
|
||||
if slot != 'icon':
|
||||
scale = {'grid': 5, 'wide': 5, 'hero': 12, 'logo': 10}[slot]
|
||||
limit = (w - w // 6) // (scale * 6)
|
||||
lines = textwrap.wrap(text, limit)[:3 if slot in ('grid', 'logo') else 2]
|
||||
if len(textwrap.wrap(text, limit)) > len(lines):
|
||||
lines[-1] = lines[-1][:-3] + '...'
|
||||
y = {'grid': 580, 'wide': 280, 'hero': 930, 'logo': (h - len(lines) * scale * 9) // 2}[slot]
|
||||
for line in lines:
|
||||
lettering(pixels, w, line, y, scale, (240, 245, 250, 255))
|
||||
y += scale * 9
|
||||
out[slot] = ('png', png(w, h, pixels))
|
||||
return out
|
||||
|
||||
def fetch(value):
|
||||
if isinstance(value, str):
|
||||
if urllib.parse.urlsplit(value).scheme not in ('http', 'https'):
|
||||
raise ValueError('artwork URLs must use HTTP(S)')
|
||||
request = urllib.request.Request(value, headers={'User-Agent': 'FrameControl/1.0'})
|
||||
with urllib.request.urlopen(request, timeout=20) as response:
|
||||
if urllib.parse.urlsplit(response.geturl()).scheme not in ('http', 'https'):
|
||||
raise ValueError('artwork redirect must use HTTP(S)')
|
||||
value = response.read(MAX_IMAGE + 1)
|
||||
return image_type(value), value
|
||||
|
||||
|
||||
def prepare(label, icon_png=None, artwork=None):
|
||||
"""Gather inputs; the Frame's Chromium canvas renders every final slot."""
|
||||
import frame_steamgriddb
|
||||
artwork = artwork or {}
|
||||
if not isinstance(artwork, dict) or set(artwork) - set(SLOTS):
|
||||
raise ValueError('artwork slots are grid, wide, hero, logo and icon')
|
||||
supplied = {}
|
||||
allowed = set(SLOTS) | {'banner', 'feature_graphic', 'screenshots', 'screenshot'}
|
||||
if not isinstance(artwork, dict) or set(artwork) - allowed:
|
||||
raise ValueError('unknown artwork slot')
|
||||
supplied, warnings = {}, []
|
||||
for slot, value in artwork.items():
|
||||
if isinstance(value, str):
|
||||
if urllib.parse.urlsplit(value).scheme not in ('http', 'https'):
|
||||
raise ValueError('artwork URLs must use HTTP(S)')
|
||||
request = urllib.request.Request(value, headers={'User-Agent': 'FrameControl/1.0'})
|
||||
with urllib.request.urlopen(request, timeout=20) as response:
|
||||
if urllib.parse.urlsplit(response.geturl()).scheme not in ('http', 'https'):
|
||||
raise ValueError('artwork redirect must use HTTP(S)')
|
||||
value = response.read(MAX_IMAGE + 1)
|
||||
supplied[slot] = (image_type(value), value)
|
||||
result = fallback(label, supplied.get('icon', ('', icon_png))[1])
|
||||
result.update(supplied)
|
||||
return result
|
||||
values = value if slot == 'screenshots' and isinstance(value, (list, tuple)) else [value]
|
||||
for candidate in values[:4]:
|
||||
try:
|
||||
supplied['screenshot' if slot == 'screenshots' else slot] = fetch(candidate)
|
||||
break
|
||||
except (ValueError, OSError):
|
||||
warnings.append('Source ' + slot + ' unavailable; using fallback art')
|
||||
if 'icon' not in supplied and icon_png:
|
||||
try:
|
||||
supplied['icon'] = fetch(icon_png)
|
||||
except (ValueError, OSError):
|
||||
pass
|
||||
provider, provider_warnings = frame_steamgriddb.lookup(label)
|
||||
warnings.extend(provider_warnings)
|
||||
for slot, value in provider.items():
|
||||
try:
|
||||
supplied[slot] = fetch(value)
|
||||
except (ValueError, OSError):
|
||||
warnings.append('SteamGridDB ' + slot + ' download failed; using fallback art')
|
||||
return supplied, warnings
|
||||
Reference in new issue
Block a user