mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 04:04:21 +02:00
Artwork sources: every optional source falls back, within limits
- Any failure of a source image or SteamGridDB (HTTPException, odd JSON) now becomes a warning and generated art, never an aborted install; refresh-art --all reports each app and carries on. - URL artwork goes through apk_sources._images: public addresses only, at most three redirects, and one overall deadline for all of an install's fetches. - PNGs are checked from their header only (any depth or interlace; Steam's Chromium decodes them), JPEGs may have trailing padding, and 4K screenshots are within limits. The slow pure-Python decoder is gone. - SteamGridDB title matching keeps letters of every script and never matches on an empty name. One warning per source slot, not per candidate. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
70897cfd1d
commit
baefa105a9
6 files changed
+158
-202
No files matched your search
+47
-133
@@ -1,13 +1,12 @@
|
||||
"""Bounded artwork inputs for Steam library canvas rendering."""
|
||||
import struct
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import time
|
||||
import zlib
|
||||
|
||||
SLOTS = {'grid': (600, 900), 'wide': (920, 430), 'hero': (3840, 1240),
|
||||
'logo': (1280, 480), 'icon': (256, 256)}
|
||||
MAX_IMAGE = 12 * 1024 * 1024
|
||||
MAX_PIXELS = 8_000_000
|
||||
MAX_PIXELS = 4096 * 4096 # a 4K screenshot; Chromium decodes it on the Frame
|
||||
PNG = b'\x89PNG\r\n\x1a\n'
|
||||
|
||||
|
||||
@@ -15,107 +14,28 @@ def chunk(kind, data):
|
||||
return struct.pack('>I', len(data)) + kind + data + struct.pack('>I', zlib.crc32(kind + data))
|
||||
|
||||
|
||||
def png(width, height, pixels):
|
||||
stride = width * 4
|
||||
raw = b''.join(b'\0' + pixels[y * stride:(y + 1) * stride] for y in range(height))
|
||||
return PNG + chunk(b'IHDR', struct.pack('>IIBBBBB', width, height, 8, 6, 0, 0, 0)) + \
|
||||
chunk(b'IDAT', zlib.compress(raw, 6)) + chunk(b'IEND', b'')
|
||||
|
||||
|
||||
def decode(data):
|
||||
"""Non-interlaced PNG, including packed palette/grayscale APK icons."""
|
||||
if not isinstance(data, bytes) or not data.startswith(PNG) or len(data) > MAX_IMAGE:
|
||||
raise ValueError('expected a PNG image (at most 12 MiB)')
|
||||
pos, packed, palette, alpha, header = 8, bytearray(), b'', b'', None
|
||||
while pos + 12 <= len(data):
|
||||
size = struct.unpack_from('>I', data, pos)[0]
|
||||
kind, body = data[pos + 4:pos + 8], data[pos + 8:pos + 8 + size]
|
||||
if pos + size + 12 > len(data):
|
||||
raise ValueError('truncated PNG')
|
||||
crc = struct.unpack_from('>I', data, pos + 8 + size)[0]
|
||||
if zlib.crc32(kind + body) != crc:
|
||||
raise ValueError('invalid PNG checksum')
|
||||
if kind == b'IHDR':
|
||||
if header is not None or size != 13:
|
||||
raise ValueError('invalid PNG header')
|
||||
header = struct.unpack('>IIBBBBB', body)
|
||||
elif kind == b'PLTE':
|
||||
palette = body
|
||||
elif kind == b'tRNS':
|
||||
alpha = body
|
||||
elif kind == b'IDAT':
|
||||
packed.extend(body)
|
||||
elif kind == b'IEND':
|
||||
break
|
||||
pos += size + 12
|
||||
else:
|
||||
raise ValueError('incomplete PNG')
|
||||
if header is None:
|
||||
raise ValueError('missing PNG header')
|
||||
w, h, depth, color, compression, filtering, interlace = header
|
||||
channels = {0: 1, 2: 3, 3: 1, 4: 2, 6: 4}.get(color)
|
||||
def png_size(data):
|
||||
"""IHDR dimensions of a PNG of any bit depth or interlace; Steam's Chromium decodes the pixels."""
|
||||
if len(data) < 33 or data[12:16] != b'IHDR' or struct.unpack_from('>I', data, 8)[0] != 13:
|
||||
raise ValueError('invalid PNG header')
|
||||
if zlib.crc32(data[12:29]) != struct.unpack_from('>I', data, 29)[0]:
|
||||
raise ValueError('invalid PNG checksum')
|
||||
w, h, depth, color = struct.unpack_from('>IIBB', data, 16)
|
||||
if color not in (0, 2, 3, 4, 6) or depth not in (1, 2, 4, 8, 16):
|
||||
raise ValueError('unsupported PNG encoding')
|
||||
if not w or not h or w * h > MAX_PIXELS or w > 8192 or h > 8192:
|
||||
raise ValueError('PNG dimensions exceed limits')
|
||||
if not channels or compression or filtering or interlace or depth not in (1, 2, 4, 8) or (depth != 8 and color not in (0, 3)):
|
||||
raise ValueError('unsupported PNG encoding')
|
||||
stride, bpp = (w * channels * depth + 7) // 8, max(1, channels * depth // 8)
|
||||
expected = h * (stride + 1)
|
||||
decoder = zlib.decompressobj()
|
||||
raw = decoder.decompress(bytes(packed), expected + 1)
|
||||
if len(raw) != expected or not decoder.eof:
|
||||
raise ValueError('invalid PNG pixels')
|
||||
pixels, prev = bytearray(), bytearray(stride)
|
||||
for y in range(h):
|
||||
start = y * (stride + 1)
|
||||
method, row = raw[start], bytearray(raw[start + 1:start + 1 + stride])
|
||||
if method > 4:
|
||||
raise ValueError('invalid PNG filter')
|
||||
for x in range(stride):
|
||||
a, b, c = row[x - bpp] if x >= bpp else 0, prev[x], prev[x - bpp] if x >= bpp else 0
|
||||
if method == 1:
|
||||
row[x] = (row[x] + a) & 255
|
||||
elif method == 2:
|
||||
row[x] = (row[x] + b) & 255
|
||||
elif method == 3:
|
||||
row[x] = (row[x] + (a + b) // 2) & 255
|
||||
elif method == 4:
|
||||
p = a + b - c
|
||||
distances = (abs(p - a), abs(p - b), abs(p - c))
|
||||
row[x] = (row[x] + (a, b, c)[distances.index(min(distances))]) & 255
|
||||
for x in range(w):
|
||||
if depth < 8:
|
||||
value = (row[x * depth // 8] >> (8 - depth - x * depth % 8)) & ((1 << depth) - 1)
|
||||
values = [value]
|
||||
else:
|
||||
values = row[x * channels:(x + 1) * channels]
|
||||
if color == 3:
|
||||
i = values[0]
|
||||
if i * 3 + 3 > len(palette):
|
||||
raise ValueError('invalid PNG palette')
|
||||
rgba = palette[i * 3:i * 3 + 3] + bytes([alpha[i] if i < len(alpha) else 255])
|
||||
elif color in (0, 4):
|
||||
gray = values[0] * 255 // ((1 << depth) - 1)
|
||||
opacity = values[1] if color == 4 else (0 if alpha == struct.pack('>H', values[0]) else 255)
|
||||
rgba = bytes([gray, gray, gray, opacity])
|
||||
else:
|
||||
opacity = values[3] if color == 6 else (0 if alpha == struct.pack('>HHH', *values) else 255)
|
||||
rgba = bytes(values[:3]) + bytes([opacity])
|
||||
pixels.extend(rgba)
|
||||
prev = row
|
||||
return w, h, pixels
|
||||
return w, h
|
||||
|
||||
|
||||
def image_type(data):
|
||||
if not isinstance(data, bytes) or len(data) > MAX_IMAGE:
|
||||
raise ValueError('artwork must be image bytes or an HTTP(S) URL, at most 12 MiB')
|
||||
if data.startswith(PNG):
|
||||
try:
|
||||
decode(data)
|
||||
except (zlib.error, struct.error) as e:
|
||||
raise ValueError('invalid PNG image') from e
|
||||
png_size(data)
|
||||
return 'png'
|
||||
if data.startswith(b'\xff\xd8') and data.endswith(b'\xff\xd9'):
|
||||
# Check JPEG SOF dimensions without depending on an image library.
|
||||
if data.startswith(b'\xff\xd8'):
|
||||
# Check JPEG SOF dimensions without depending on an image library; trailing padding is fine.
|
||||
pos = 2
|
||||
while pos + 4 <= len(data) and data[pos] == 255:
|
||||
marker = data[pos + 1]
|
||||
@@ -126,7 +46,7 @@ def image_type(data):
|
||||
size = struct.unpack_from('>H', data, pos)[0]
|
||||
if size < 2 or pos + size > len(data):
|
||||
break
|
||||
if marker in (0xc0, 0xc1, 0xc2) and size >= 8:
|
||||
if 0xc0 <= marker <= 0xcf and marker not in (0xc4, 0xc8, 0xcc) and size >= 8:
|
||||
h, w = struct.unpack_from('>HH', data, pos + 3)
|
||||
if w and h and w * h <= MAX_PIXELS and max(w, h) <= 8192:
|
||||
return 'jpg'
|
||||
@@ -135,59 +55,53 @@ def image_type(data):
|
||||
raise ValueError('artwork must be a supported PNG or JPEG')
|
||||
|
||||
|
||||
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))
|
||||
x, y = x + (size - dw) // 2, y + (size - dh) // 2
|
||||
for yy in range(dh):
|
||||
for xx in range(dw):
|
||||
src = ((yy * ih // dh) * iw + xx * iw // dw) * 4
|
||||
dst = ((y + yy) * width + x + xx) * 4
|
||||
a = source[src + 3]
|
||||
for c in range(3):
|
||||
pixels[dst + c] = (source[src + c] * a + pixels[dst + c] * (255 - a)) // 255
|
||||
pixels[dst + 3] = a + pixels[dst + 3] * (255 - a) // 255
|
||||
|
||||
|
||||
|
||||
def fetch(value):
|
||||
def fetch(value, deadline=None):
|
||||
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)
|
||||
from apk_sources import _images
|
||||
# Public addresses only, at most three redirects, within the overall deadline.
|
||||
value = _images.fetch(value, deadline=deadline, limit=MAX_IMAGE)[0]
|
||||
return image_type(value), value
|
||||
|
||||
|
||||
def prepare(label, icon_png=None, artwork=None):
|
||||
"""Gather inputs; the Frame's Chromium canvas renders every final slot."""
|
||||
def prepare(label, icon_png=None, artwork=None, budget=90):
|
||||
"""Gather inputs; the Frame's Chromium canvas renders every final slot.
|
||||
|
||||
Every source is optional: any failure falls back to generated art, within budget seconds overall."""
|
||||
import frame_steamgriddb
|
||||
artwork = artwork or {}
|
||||
allowed = set(SLOTS) | {'banner', 'feature_graphic', 'screenshots', 'screenshot'}
|
||||
if not isinstance(artwork, dict) or set(artwork) - allowed:
|
||||
raise ValueError('unknown artwork slot')
|
||||
deadline = time.monotonic() + budget
|
||||
supplied, warnings = {}, []
|
||||
|
||||
def get(value):
|
||||
try:
|
||||
return fetch(value, deadline)
|
||||
except Exception: # an optional source never blocks the install; generated art covers it
|
||||
return None
|
||||
for slot, value in artwork.items():
|
||||
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)
|
||||
image = get(candidate)
|
||||
if image:
|
||||
supplied['screenshot' if slot == 'screenshots' else slot] = image
|
||||
break
|
||||
except (ValueError, OSError):
|
||||
warnings.append('Source ' + slot + ' unavailable; using fallback art')
|
||||
else:
|
||||
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)
|
||||
image = get(icon_png)
|
||||
if image:
|
||||
supplied['icon'] = image
|
||||
try:
|
||||
provider, provider_warnings = frame_steamgriddb.lookup(label, deadline)
|
||||
except Exception:
|
||||
provider, provider_warnings = {}, ['SteamGridDB unavailable; using source or generated art']
|
||||
warnings.extend(provider_warnings)
|
||||
for slot, value in provider.items():
|
||||
try:
|
||||
supplied[slot] = fetch(value)
|
||||
except (ValueError, OSError):
|
||||
image = get(value)
|
||||
if image:
|
||||
supplied[slot] = image
|
||||
else:
|
||||
warnings.append('SteamGridDB ' + slot + ' download failed; using fallback art')
|
||||
return supplied, warnings
|
||||
Reference in new issue
Block a user