ps1: official-style ICON0/PIC0/PIC1 artwork with PSN templates

Replaces the letterboxed 144x80 icon + cropped PIC1 with official-looking
PS1 Classics art:
- ICON0 (80x80): cover art composited into the bundled case frame (PS logo
  + 'PlayStation.' wordmark) from data/ps1_templates/ICON0.PNG.
- PIC0 (310x180): a PSN-style info board drawn in code — title, IGDB
  description, ESRB badge (from IGDB age_ratings) and PS3/PSP badges.
- PIC1 (480x272): the bundled launch background template.

The converter now embeds PIC0, the PS1 tab previews all three images, and
ps1_art pulls IGDB description/ESRB as best effort. Templates are git-ignored
(user-supplied, like base.PBP) with a drawn-art fallback when missing.
This commit is contained in:
2026-09-07 19:23:38 -04:00
parent 2433240628
commit f6dccbec24
6 changed files with 325 additions and 72 deletions
+257 -29
View File
@@ -808,23 +808,63 @@ def compose_game_cover(key_art_bytes):
return bytes(ba)
def make_ps1_assets(cover_bytes):
"""Build ICON0.PNG (144x80) and PIC1.PNG (480x272) from PS1 cover art.
_PS1_TEMPLATE_DIR = config.DATA_DIR / "ps1_templates"
Returns ``(icon0_bytes, pic1_bytes)``, or ``(None, None)`` when the art
can't be decoded (or Qt isn't available). ICON0 is the art letterboxed
inside the icon frame; PIC1 is the art cropped to fill the launch
background.
def _ps1_template_bytes(name):
"""Read a bundled PS1 art template (``ICON0.PNG`` / ``PIC1.PNG``)."""
try:
p = _PS1_TEMPLATE_DIR / name
if p.is_file():
return p.read_bytes()
except OSError:
pass
return None
def _slot_rect(img):
"""Find the fully-transparent cover slot in an icon template image.
Returns ``(x, y, w, h)`` or ``None`` when the image has no transparent
region (e.g. a flattened template)."""
if img.isNull():
return None
w, h = img.width(), img.height()
min_x = min_y = 1 << 30
max_x = max_y = -1
for y in range(h):
for x in range(w):
if img.pixelColor(x, y).alpha() < 10:
min_x, max_x = min(min_x, x), max(max_x, x)
min_y, max_y = min(min_y, y), max(max_y, y)
if max_x < min_x or max_y < min_y:
return None
return min_x, min_y, max_x - min_x + 1, max_y - min_y + 1
def make_ps1_assets(cover_bytes, title="", description="", esrb=None):
"""Build official-style PS1 Classics art from the cover: (icon0, pic0, pic1).
- ``ICON0`` (80x80): the cover composited into the bundled case template
(PS button logo, ``PlayStation.`` wordmark, gradient frame).
- ``PIC0`` (310x180): the PSN-style info board — title, description,
ESRB rating badge and PS3/PSP platform badges.
- ``PIC1`` (480x272): the bundled launch background (gradient + curve).
Returns ``(None, None, None)`` when the art can't be decoded or Qt is
unavailable.
"""
try:
from PySide6.QtCore import QBuffer, QByteArray, Qt
from PySide6.QtGui import QImage, QPainter
from PySide6.QtCore import QBuffer, QByteArray, QPointF, QRectF, Qt
from PySide6.QtGui import (QColor, QFont, QFontMetrics, QImage,
QLinearGradient, QPainter, QPainterPath,
QPen, QPolygonF)
except Exception: # noqa: BLE001 - headless API may lack PySide6
return None, None
return None, None, None
art = QImage.fromData(cover_bytes)
if art.isNull():
return None, None
return None, None, None
art = art.convertToFormat(QImage.Format_ARGB32)
def _save(img):
@@ -835,43 +875,215 @@ def make_ps1_assets(cover_bytes):
buf.close()
return bytes(ba)
# ICON0: fit the cover inside 144x80 (transparent margins).
icon = QImage(144, 80, QImage.Format_ARGB32)
icon.fill(Qt.transparent)
# --- ICON0: cover art inside the case template ---------------------------
icon = QImage(80, 80, QImage.Format_ARGB32)
icon.fill(QColor(0, 0, 0))
p = QPainter(icon)
scaled = art.scaled(144, 80, Qt.KeepAspectRatio, Qt.SmoothTransformation)
p.drawImage((144 - scaled.width()) // 2, (80 - scaled.height()) // 2, scaled)
p.setRenderHint(QPainter.SmoothPixmapTransform, True)
tmpl = QImage.fromData(_ps1_template_bytes("ICON0.PNG") or b"")
if not tmpl.isNull():
tmpl = tmpl.convertToFormat(QImage.Format_ARGB32)
slot = _slot_rect(tmpl) or (0, 0, 80, 80)
sx, sy, sw, sh = slot
cover = art.scaled(sw, sh, Qt.KeepAspectRatioByExpanding, Qt.SmoothTransformation)
p.drawImage(sx + (sw - cover.width()) // 2, sy + (sh - cover.height()) // 2, cover)
p.drawImage(0, 0, tmpl)
else:
cover = art.scaled(80, 80, Qt.KeepAspectRatio, Qt.SmoothTransformation)
p.drawImage((80 - cover.width()) // 2, (80 - cover.height()) // 2, cover)
p.end()
icon0 = _save(icon)
# PIC1: crop the cover to fill 480x272.
# --- PIC0: PSN-style info board -----------------------------------------
pic0 = _make_pic0(title, description, esrb, QImage, QPainter, QPainterPath,
QColor, QFont, QFontMetrics, QLinearGradient, QPen,
QPolygonF, QPointF, QRectF, Qt, _save)
# --- PIC1: bundled launch background ------------------------------------
pic1 = _make_pic1(QImage, QPainter, QColor, QLinearGradient, Qt, _save)
return icon0, pic0, pic1
def _make_pic1(QImage, QPainter, QColor, QLinearGradient, Qt, _save):
pic = QImage(480, 272, QImage.Format_ARGB32)
pic.fill(Qt.transparent)
raw = _ps1_template_bytes("PIC1.PNG")
tmpl = QImage.fromData(raw) if raw else QImage()
if not tmpl.isNull():
return _save(tmpl.convertToFormat(QImage.Format_ARGB32))
# Fallback: a light lavender gradient with a subtle curve (same vibe as
# the official background) for when the template image is missing.
grad = QLinearGradient(0, 0, 0, 272)
grad.setColorAt(0.0, QColor(235, 232, 238))
grad.setColorAt(1.0, QColor(205, 200, 215))
p = QPainter(pic)
scaled = art.scaled(480, 272, Qt.KeepAspectRatioByExpanding, Qt.SmoothTransformation)
p.drawImage((480 - scaled.width()) // 2, (272 - scaled.height()) // 2, scaled)
p.fillRect(0, 0, 480, 272, grad)
p.setPen(QPen(QColor(170, 160, 185, 120), 3))
path = QPainterPath()
path.moveTo(-40, 300)
path.quadTo(240, 60, 520, 300)
p.drawPath(path)
p.end()
return _save(pic)
return _save(icon), _save(pic)
def _make_pic0(title, description, esrb, QImage, QPainter, QPainterPath, QColor,
QFont, QFontMetrics, QLinearGradient, QPen, QPolygonF, QPointF,
QRectF, Qt, _save):
W, H = 310, 180
img = QImage(W, H, QImage.Format_ARGB32)
# Dark gradient backdrop.
grad = QLinearGradient(0, 0, 0, H)
grad.setColorAt(0.0, QColor(38, 38, 40))
grad.setColorAt(1.0, QColor(12, 12, 14))
p = QPainter(img)
p.setRenderHint(QPainter.Antialiasing, True)
p.setRenderHint(QPainter.TextAntialiasing, True)
p.fillRect(0, 0, W, H, grad)
# Rounded border.
p.setPen(QPen(QColor(110, 110, 115), 1))
p.setBrush(Qt.NoBrush)
p.drawRoundedRect(QRectF(4, 4, W - 8, H - 8), 9, 9)
# PlayStation button glyphs, top-right.
_draw_ps_glyphs(p, W - 34, 12, 22, QColor(200, 200, 205), QRectF, QPointF,
QPolygonF, QPen, Qt)
# Title (bold, upper-left).
f_title = QFont("DejaVu Sans")
f_title.setPixelSize(19)
f_title.setBold(True)
p.setFont(f_title)
p.setPen(QColor(255, 255, 255))
p.drawText(QRectF(18, 16, W - 64, 28), Qt.AlignLeft | Qt.AlignVCenter,
title or "")
# Description (wrapped, below title).
if description:
f_desc = QFont("DejaVu Sans")
f_desc.setPixelSize(12)
p.setFont(f_desc)
p.setPen(QColor(215, 215, 215))
text = description.strip()
fm = QFontMetrics(f_desc)
# Rough 3-line budget (~40 chars/line at 12px across 272px).
if fm.horizontalAdvance(text) > 3 * (W - 36):
text = text[:130].rstrip() + "\u2026"
p.drawText(QRectF(18, 46, W - 36, 74),
Qt.AlignLeft | Qt.AlignTop | Qt.TextWordWrap, text)
# ESRB badge (bottom-left) + caption.
if esrb:
badge = esrb.strip()
if badge:
_draw_esrb(p, badge, 18, 140, QRectF, QColor, QFont, QFontMetrics, QPen, Qt)
# Platform badges (bottom-right).
_draw_platform_badge(p, "PS3", W - 14 - 64, 146, QColor(94, 146, 210),
QRectF, QColor, QFont, QFontMetrics, Qt)
_draw_platform_badge(p, "PSP", W - 14 - 30, 146, QColor(212, 175, 55),
QRectF, QColor, QFont, QFontMetrics, Qt)
p.end()
return _save(img)
def _draw_ps_glyphs(p, x, y, size, color, QRectF, QPointF, QPolygonF, QPen, Qt):
"""Draw the four PlayStation face-button glyphs in a 2x2 grid."""
cell = size / 2.0
pen = QPen(color, max(1.0, size / 14.0))
pen.setCapStyle(Qt.RoundCap)
pen.setJoinStyle(Qt.RoundJoin)
p.setPen(pen)
p.setBrush(Qt.NoBrush)
# Triangle (top-left).
tri = QPolygonF([QPointF(x, y + cell), QPointF(x + cell, y), QPointF(x + cell, y + cell)])
p.drawPolygon(tri)
# Circle (top-right).
p.drawEllipse(QRectF(x + cell, y, cell, cell))
# Cross (bottom-left).
p.drawLine(QPointF(x + 1, y + cell + 1), QPointF(x + cell - 1, y + size - 1))
p.drawLine(QPointF(x + cell - 1, y + cell + 1), QPointF(x + 1, y + size - 1))
# Square (bottom-right).
p.drawRect(QRectF(x + cell, y + cell, cell, cell))
def _draw_esrb(p, badge, x, y, QRectF, QColor, QFont, QFontMetrics, QPen, Qt):
f = QFont("DejaVu Sans")
f.setPixelSize(9)
f.setBold(True)
fm = QFontMetrics(f)
text = badge if badge != "EVERYONE" else "EVERYONE"
# Shrink until it fits the box (the official badge shows the word).
size = 9
while size > 5 and fm.horizontalAdvance(text) > 46:
size -= 1
f.setPixelSize(size)
fm = QFontMetrics(f)
rect = QRectF(x, y, 50, 24)
p.setPen(QPen(QColor(20, 20, 20), 1))
p.setBrush(QColor(255, 255, 255))
p.drawRoundedRect(rect, 3, 3)
p.setPen(QColor(20, 20, 20))
p.setFont(f)
p.drawText(rect, Qt.AlignCenter, text)
# Caption.
fc = QFont("DejaVu Sans")
fc.setPixelSize(7)
p.setFont(fc)
p.setPen(QColor(150, 150, 155))
p.drawText(QRectF(x + 56, y + 6, 120, 14),
Qt.AlignLeft | Qt.AlignVCenter, "ESRB CONTENT RATING")
def _draw_platform_badge(p, label, x, y, color, QRectF, QColor, QFont,
QFontMetrics, Qt):
f = QFont("DejaVu Sans")
f.setPixelSize(10)
f.setBold(True)
rect = QRectF(x, y, 30, 16)
p.setPen(Qt.NoPen)
p.setBrush(QColor(26, 26, 28))
p.drawRoundedRect(rect, 4, 4)
p.setPen(color)
p.setFont(f)
p.drawText(rect, Qt.AlignCenter, label)
def ps1_art(title, release_id, timeout=10):
"""Fetch a PS1 game's cover and return ``(icon0, pic1)`` PNG bytes.
"""Fetch a PS1 game's cover and build ``(icon0, pic0, pic1)`` PNG bytes.
Reuses the local cover cache when available, otherwise searches the PS1
sources (libretro, ScreenScraper, IGDB, Wikipedia). Returns
``(None, None)`` when no art can be produced.
sources (libretro, ScreenScraper, IGDB, Wikipedia). The description and
ESRB rating for the PIC0 info board come from IGDB (best effort).
Returns ``(None, None, None)`` when no art can be produced.
"""
key = release_id or stable_key(title)
try:
path = fetch_cover(title, key, "ps1", timeout=timeout)
except Exception: # noqa: BLE001
return None, None
return None, None, None
if path is None:
return None, None
return None, None, None
description = ""
esrb = None
try:
return make_ps1_assets(path.read_bytes())
meta = get_game_metadata_igdb(title, timeout=timeout, system="ps1")
if meta:
description = meta.get("overview") or ""
esrb = meta.get("esrb")
except Exception: # noqa: BLE001 - metadata is best effort
pass
try:
return make_ps1_assets(path.read_bytes(), title=title,
description=description, esrb=esrb)
except OSError:
return None, None
return None, None, None
# --------------------------------------------------------------------------- #
@@ -1321,6 +1533,21 @@ def _igdb_date(ts):
return ""
# IGDB ``age_ratings.rating`` values for ESRB (category 1). 6=RP, 7=EC, 8=E,
# 9=E10+, 10=T, 11=M, 12=AO. The badge uses the short letter form.
_ESRB_BADGE = {6: "RP", 7: "EC", 8: "EVERYONE", 9: "EVERYONE 10+",
10: "TEEN", 11: "MATURE", 12: "ADULTS ONLY"}
def _igdb_esrb(g):
for ar in g.get("age_ratings") or []:
if not isinstance(ar, dict):
continue
if ar.get("category") == 1: # ESRB
return _ESRB_BADGE.get(ar.get("rating"))
return None
def _igdb_game_meta(g):
genres = [x.get("name") for x in (g.get("genres") or []) if isinstance(x, dict) and x.get("name")]
devs, pubs = [], []
@@ -1353,6 +1580,7 @@ def _igdb_game_meta(g):
"genres": list(dict.fromkeys(genres)),
"players": None,
"rating": rating,
"esrb": _igdb_esrb(g),
"youtube": yt,
}
@@ -1367,7 +1595,7 @@ def _igdb_search(query, limit=20, timeout=10, system="games"):
f'search "{term}"; '
"fields name, cover.image_id, first_release_date, summary, genres.name, "
"involved_companies.company.name, involved_companies.developer, involved_companies.publisher, "
"rating, videos.video_id; "
"rating, videos.video_id, age_ratings.category, age_ratings.rating; "
f"where platforms = ({platform_id}); "
f"limit {max(limit, 1)};"
)
@@ -1426,7 +1654,7 @@ def game_metadata_by_id_igdb(game_id, timeout=10):
q = (
"fields name, cover.image_id, first_release_date, summary, genres.name, "
"involved_companies.company.name, involved_companies.developer, involved_companies.publisher, "
"rating, videos.video_id; "
"rating, videos.video_id, age_ratings.category, age_ratings.rating; "
f"where id = {int(game_id)}; limit 1;"
)
games = _igdb_post(q, timeout) or []