mirror of
https://github.com/spoopyghosty0/frameport.git
synced 2026-10-06 21:00:42 +02:00
2D Android apps get artwork in FramePort: the APK's launcher icon (manifest icon first, any bitmap density) and a generated cover/banner when no store art exists (scan time + one background backfill); one icon extractor for FramePort and Steam
This commit is contained in:
1 parent
e729f3f4bc
commit
cc3febfae4
8 files changed
+152
-57
No files matched your search
@@ -20,7 +20,8 @@ KINDS = {
|
||||
"APP_IMG_LOGO_TRANSPARENT": "logo",
|
||||
"APP_IMG_ICON": "icon",
|
||||
}
|
||||
EXTRA_KINDS = ("square",) # OculusDB's square cover (Rift games)
|
||||
# OculusDB's square cover (Rift games); FramePort's own cover/banner (no store art)
|
||||
EXTRA_KINDS = ("square", "cover", "banner")
|
||||
PICKED = ".picked" # marker: the art in this folder was chosen by the user (Find artwork); automatic fetches skip it
|
||||
|
||||
|
||||
@@ -76,21 +77,43 @@ def fetch(package: str, apk: Path | None = None, refresh: bool = False,
|
||||
return out, title
|
||||
|
||||
|
||||
def apk_icon(apk: Path) -> bytes | None:
|
||||
"""Largest PNG launcher icon inside the APK (mipmap/drawable)."""
|
||||
def apk_icon(apk: Path | str | None) -> bytes | None:
|
||||
"""The app's launcher icon from the APK as PNG bytes: the icon the manifest names (pyaxmlparser), else the
|
||||
largest ic_launcher bitmap. None for vector/adaptive-only icons (no bitmap to use)."""
|
||||
if not apk or not Path(apk).is_file():
|
||||
return None
|
||||
import io
|
||||
|
||||
from PIL import Image
|
||||
|
||||
dens = ("xxxhdpi", "xxhdpi", "xhdpi", "hdpi", "mdpi")
|
||||
try:
|
||||
with zipfile.ZipFile(apk) as z:
|
||||
cands = [i for i in z.infolist() if i.filename.startswith("res/") and "ic_launcher" in i.filename
|
||||
and i.filename.endswith((".png", ".webp"))]
|
||||
if not cands:
|
||||
cands = [i for i in z.infolist() if i.filename.startswith("res/mipmap") and i.filename.endswith(".png")]
|
||||
if not cands:
|
||||
return None
|
||||
best = max(cands, key=lambda i: i.file_size)
|
||||
data = z.read(best)
|
||||
return data if data[:4] == b"\x89PNG" else None
|
||||
except (zipfile.BadZipFile, OSError):
|
||||
listed = set(z.namelist())
|
||||
names = []
|
||||
try:
|
||||
from pyaxmlparser import APK
|
||||
|
||||
named = APK(str(apk)).get_app_icon()
|
||||
if named:
|
||||
names.append(named)
|
||||
except Exception: # noqa: BLE001 - unreadable resources: fall back to the usual file names
|
||||
pass
|
||||
names += sorted((n for n in listed if n.startswith("res/") and "ic_launcher" in n
|
||||
and n.endswith((".png", ".webp")) and "foreground" not in n and "background" not in n),
|
||||
key=lambda n: next((i for i, d in enumerate(dens) if d in n), len(dens)))
|
||||
for n in names:
|
||||
if n not in listed or not n.endswith((".png", ".webp")):
|
||||
continue
|
||||
with Image.open(io.BytesIO(z.read(n))) as im:
|
||||
if im.width < 48:
|
||||
continue
|
||||
out = io.BytesIO()
|
||||
im.convert("RGBA").save(out, "PNG")
|
||||
return out.getvalue()
|
||||
except (zipfile.BadZipFile, OSError, ValueError):
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def files(package: str) -> list[Path]:
|
||||
|
||||
@@ -126,7 +126,44 @@ def steam_set(package: str, title: str = "", apk: str | Path | None = None) -> d
|
||||
return result
|
||||
|
||||
|
||||
PLACEHOLDER_VERSION = 1
|
||||
PLACEHOLDER_VERSION = 2
|
||||
|
||||
|
||||
STORE_KINDS = {"portrait", "square", "landscape", "hero"}
|
||||
|
||||
|
||||
def ensure_cover(package: str) -> Path | None:
|
||||
"""A cover for FramePort's own library when a game has no store art (e.g. a 2D Android app): the same name +
|
||||
APK icon design as the Steam placeholder, saved as artwork/<pkg>/cover.jpg. None when store art exists."""
|
||||
from ..core import library
|
||||
|
||||
d = fetch.artwork_dir(package)
|
||||
if {p.stem for p in fetch.files(package)} & STORE_KINDS:
|
||||
return None
|
||||
g = library.game(package) or {}
|
||||
if not any(d.glob("icon.*")): # the icon also shows in lists (Frame page)
|
||||
icon = fetch.apk_icon(g.get("apk"))
|
||||
if icon:
|
||||
(d / "icon.png").write_bytes(icon)
|
||||
# icon-only (the card and the game page show the name anyway): a tall cover for cards, a wide banner for the page
|
||||
title = g.get("title") or package
|
||||
cover, banner = d / "cover.jpg", d / "banner.jpg"
|
||||
stamp = d / ".cover"
|
||||
key = f"{PLACEHOLDER_VERSION}:{title}:{next((p.stat().st_size for p in d.glob('icon.*')), 0)}"
|
||||
if cover.exists() and banner.exists() and stamp.exists() and stamp.read_text(encoding="utf-8") == key:
|
||||
return cover
|
||||
icon = _load_icon(next(iter(sorted(d.glob("icon.*"))), None), g.get("apk"))
|
||||
background = _background_for(title)
|
||||
# cover: icon a bit above the middle (the card's title sits at the bottom); banner: icon on the right (the game
|
||||
# page draws the title on the left)
|
||||
for path, size, side, centre in ((cover, (600, 900), 300, (0.5, 0.4)), (banner, (1600, 600), 320, (0.75, 0.5))):
|
||||
im = background(size)
|
||||
if icon is not None:
|
||||
ic = icon.resize((side, side))
|
||||
im.paste(ic, (round(size[0] * centre[0] - side / 2), round(size[1] * centre[1] - side / 2)), ic)
|
||||
im.save(path, "JPEG", quality=90, optimize=True)
|
||||
stamp.write_text(key, encoding="utf-8")
|
||||
return cover
|
||||
|
||||
|
||||
def steam_set_for(package: str) -> dict[str, Path]:
|
||||
@@ -138,38 +175,16 @@ def steam_set_for(package: str) -> dict[str, Path]:
|
||||
|
||||
|
||||
def _apk_icon(apk: str | Path | None):
|
||||
"""The APK's launcher icon as a PIL image, or None (adaptive/vector icons have no bitmap to use)."""
|
||||
if not apk or not Path(apk).is_file():
|
||||
return None
|
||||
"""The APK's launcher icon as a PIL image, or None (see fetch.apk_icon)."""
|
||||
import io
|
||||
import zipfile
|
||||
|
||||
from PIL import Image
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(apk) as z:
|
||||
names = []
|
||||
try:
|
||||
from pyaxmlparser import APK
|
||||
|
||||
icon = APK(str(apk)).get_app_icon()
|
||||
if icon:
|
||||
names.append(icon)
|
||||
except Exception: # noqa: BLE001 - unreadable resources: fall back to the usual file names
|
||||
pass
|
||||
dens = ("xxxhdpi", "xxhdpi", "xhdpi", "hdpi", "mdpi")
|
||||
names += sorted((n for n in z.namelist() if "ic_launcher" in n and n.endswith((".png", ".webp"))
|
||||
and "foreground" not in n and "background" not in n),
|
||||
key=lambda n: next((i for i, d in enumerate(dens) if d in n), len(dens)))
|
||||
for n in names:
|
||||
if n.endswith((".png", ".webp")) and n in z.namelist():
|
||||
im = Image.open(io.BytesIO(z.read(n)))
|
||||
im.load()
|
||||
if im.width >= 48:
|
||||
return im.convert("RGBA")
|
||||
except Exception: # noqa: BLE001 - not a readable APK
|
||||
data = fetch.apk_icon(apk)
|
||||
if not data:
|
||||
return None
|
||||
return None
|
||||
with Image.open(io.BytesIO(data)) as im:
|
||||
return im.convert("RGBA")
|
||||
|
||||
|
||||
def _font(size: int):
|
||||
@@ -209,28 +224,40 @@ def _title_block(draw, text: str, box: tuple[int, int, int, int], max_size: int,
|
||||
draw.text((x0 + (x1 - x0 - w) / 2, top + i * line_h), line, font=font, fill=fill)
|
||||
|
||||
|
||||
def _placeholder(out_dir: Path, title: str, icon_file: Path | None, apk) -> dict[str, Path]:
|
||||
"""Name-on-colour art (portrait, landscape, hero, logo, icon), the hue picked from the title."""
|
||||
def _background_for(title: str):
|
||||
"""A vertical gradient in a colour picked from the title: background(size) -> PIL image."""
|
||||
import colorsys
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
from PIL import Image
|
||||
|
||||
hue = int(hashlib.sha1(title.encode()).hexdigest()[:4], 16) / 0xFFFF
|
||||
top = tuple(round(c * 255) for c in colorsys.hls_to_rgb(hue, 0.30, 0.45))
|
||||
bottom = tuple(round(c * 255) for c in colorsys.hls_to_rgb(hue, 0.10, 0.40))
|
||||
icon = None
|
||||
if icon_file:
|
||||
try:
|
||||
with Image.open(icon_file) as im:
|
||||
icon = im.convert("RGBA")
|
||||
except Exception: # noqa: BLE001
|
||||
icon = None
|
||||
if icon is None:
|
||||
icon = _apk_icon(apk)
|
||||
|
||||
def background(size):
|
||||
mask = Image.linear_gradient("L").resize(size) # 0 at the top → 255 at the bottom
|
||||
return Image.composite(Image.new("RGB", size, bottom), Image.new("RGB", size, top), mask)
|
||||
return background
|
||||
|
||||
|
||||
def _load_icon(icon_file: Path | None, apk):
|
||||
from PIL import Image
|
||||
|
||||
if icon_file:
|
||||
try:
|
||||
with Image.open(icon_file) as im:
|
||||
return im.convert("RGBA")
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
return _apk_icon(apk)
|
||||
|
||||
|
||||
def _placeholder(out_dir: Path, title: str, icon_file: Path | None, apk) -> dict[str, Path]:
|
||||
"""Name-on-colour art (portrait, landscape, hero, logo, icon), the hue picked from the title."""
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
icon = _load_icon(icon_file, apk)
|
||||
background = _background_for(title)
|
||||
|
||||
result: dict[str, Path] = {}
|
||||
for kind, (w, h) in SIZES.items():
|
||||
|
||||
@@ -12,7 +12,8 @@ from pathlib import Path
|
||||
from ..core.paths import user_data_dir
|
||||
from . import fetch
|
||||
|
||||
WIDTHS = {"portrait": 400, "square": 400, "landscape": 1280, "hero": 1280, "icon": 96, "logo": 600}
|
||||
WIDTHS = {"portrait": 400, "square": 400, "cover": 400, "banner": 1280, "landscape": 1280, "hero": 1280, "icon": 96,
|
||||
"logo": 600}
|
||||
_locks: dict[str, threading.Lock] = {}
|
||||
_guard = threading.Lock()
|
||||
|
||||
|
||||
@@ -59,6 +59,9 @@ def add_path(path: Path, reporter: Reporter | None = None, on_added=None, force_
|
||||
if art:
|
||||
try:
|
||||
artwork.fetch(entry["package"], Path(entry["apk"]))
|
||||
from .artwork.steam import ensure_cover
|
||||
|
||||
ensure_cover(entry["package"]) # no store art (2D apps): a cover with its name and icon
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
if not entry.get("details"):
|
||||
|
||||
@@ -98,6 +98,7 @@ class FramePortApp:
|
||||
self.go("welcome" if needed() else "library")
|
||||
threading.Thread(target=self._startup, daemon=True).start()
|
||||
threading.Thread(target=self._poll, daemon=True).start()
|
||||
threading.Thread(target=self._backfill_covers, daemon=True).start()
|
||||
self.updater.start()
|
||||
|
||||
# ================================================================== shell
|
||||
@@ -1293,6 +1294,22 @@ class FramePortApp:
|
||||
self.refresh_view()
|
||||
return True
|
||||
|
||||
def _backfill_covers(self) -> None:
|
||||
"""Games without store art (2D Android apps) get a cover with their name and icon (once; cached)."""
|
||||
from ..artwork import steam
|
||||
|
||||
made = 0
|
||||
for g in library.games():
|
||||
if g.get("kind") == "rift":
|
||||
continue
|
||||
try:
|
||||
had = (steam.fetch.artwork_dir(g["package"]) / "cover.jpg").exists()
|
||||
made += bool(steam.ensure_cover(g["package"])) and not had
|
||||
except Exception: # noqa: BLE001 - artwork is optional
|
||||
applog.log.info("cover for %s failed", g.get("package"), exc_info=True)
|
||||
if made:
|
||||
self.refresh_view()
|
||||
|
||||
def _keep_frame_awake(self) -> None:
|
||||
"""Hold a wake lock on the Frame while jobs that use it run or wait (renewed every 30 min; it expires on its
|
||||
own after an hour if FramePort goes away), release it when they're done."""
|
||||
|
||||
@@ -49,7 +49,7 @@ class GameView:
|
||||
from .library import display_title
|
||||
|
||||
a = g["analysis"]
|
||||
art = thumbs.url(pkg, ("hero", "landscape", "portrait", "square"), 1280, wait=False)
|
||||
art = thumbs.url(pkg, ("hero", "landscape", "portrait", "square", "banner"), 1280, wait=False)
|
||||
platform = C.platform(g)[1]
|
||||
facts = " · ".join(x for x in (platform, a.get("engine"), a.get("xr")) if x and x != "?")
|
||||
recipe = g.get("recipe") or {}
|
||||
|
||||
@@ -132,6 +132,7 @@ def save_filters(f: dict) -> None:
|
||||
|
||||
# ------------------------------------------------------------------------------------------ view
|
||||
BATCH = 8
|
||||
CARD_ART = ("portrait", "square", "cover", "icon") # store art first; cover = FramePort's own (no store art)
|
||||
def card_shadow(hover: bool = False) -> ft.BoxShadow:
|
||||
"""Library cards float on the dark background; hovering lifts them further."""
|
||||
if hover:
|
||||
@@ -511,14 +512,14 @@ class LibraryView:
|
||||
pkg = g["package"]
|
||||
job = self.app.jobs.busy_with(pkg)
|
||||
return (display_title(g, tw), (g.get("recipe") or {}).get("status"), C.install_state(g, self.app.frame_info),
|
||||
pkg in pc, bool(job), self.app.quick_action(g)[0], thumbs.url(pkg, ("portrait", "square", "icon")),
|
||||
pkg in pc, bool(job), self.app.quick_action(g)[0], thumbs.url(pkg, CARD_ART),
|
||||
self.app.frame_state)
|
||||
|
||||
def card(self, g: dict, pc: set[str], tw: set[str]) -> ft.Control:
|
||||
app = self.app
|
||||
pkg = g["package"]
|
||||
rift = g.get("kind") == "rift"
|
||||
art = thumbs.url(pkg, ("portrait", "square", "icon"))
|
||||
art = thumbs.url(pkg, CARD_ART)
|
||||
state = C.install_state(g, app.frame_info)
|
||||
on_pc = pkg in pc
|
||||
status = (g.get("recipe") or {}).get("status", "unknown")
|
||||
|
||||
@@ -310,3 +310,26 @@ def test_picked_art_survives_installs(monkeypatch):
|
||||
assert (d / "portrait.png").read_bytes() == png("blue") and (d / "icon.png").exists()
|
||||
fetch.fetch("com.picked", refresh=True) # "Find automatically": the store art replaces everything
|
||||
assert (d / "portrait.png").read_bytes() == png("red")
|
||||
|
||||
|
||||
def test_apps_without_store_art_get_a_cover(tmp_path):
|
||||
"""FramePort's library shows a cover (name + APK icon) for apps no store knows; store art always wins."""
|
||||
import zipfile
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from frameport.artwork import fetch, steam, thumbs
|
||||
from frameport.core import library
|
||||
|
||||
icon = tmp_path / "icon.png"
|
||||
Image.new("RGBA", (96, 96), (10, 200, 30, 255)).save(icon)
|
||||
apk = tmp_path / "app.apk"
|
||||
with zipfile.ZipFile(apk, "w") as z:
|
||||
z.write(icon, "res/mipmap-xxxhdpi-v4/ic_launcher.png")
|
||||
library.upsert_game("org.example.flat", title="Flat App", apk=str(apk))
|
||||
cover = steam.ensure_cover("org.example.flat")
|
||||
assert cover and cover.name == "cover.jpg" and (fetch.artwork_dir("org.example.flat") / "icon.png").exists()
|
||||
assert thumbs.pick("org.example.flat", ("portrait", "square", "cover", "icon")).name == "cover.jpg"
|
||||
Image.new("RGB", (60, 90)).save(fetch.artwork_dir("org.example.flat") / "portrait.jpg") # store art arrives
|
||||
assert steam.ensure_cover("org.example.flat") is None
|
||||
assert thumbs.pick("org.example.flat", ("portrait", "square", "cover", "icon")).name == "portrait.jpg"
|
||||
Reference in new issue
Block a user