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
+4
View File
@@ -10,3 +10,7 @@ session-transcript-2026-09-01.md
# Copyrighted PSN EBOOTs (base.pbp) used only for local PS1 conversion testing # Copyrighted PSN EBOOTs (base.pbp) used only for local PS1 conversion testing
data/*.PBP data/*.PBP
data/*.pbp data/*.pbp
# User-supplied PS1 Classics art templates (extracted from copyrighted PSN
# EBOOTs) — kept local only; the app falls back to drawn art when absent.
data/ps1_templates/
+13 -4
View File
@@ -97,6 +97,9 @@ Discs are matched by hash first (most precise), then serial, then filename.
+ a base `PARAM.SFO`/icon for PS1 conversion (git-ignored, copyrighted). + a base `PARAM.SFO`/icon for PS1 conversion (git-ignored, copyrighted).
- `data/loader/DATA.PSP` + `data/loader/KEYS.BIN` — bundled plain-ELF POPS - `data/loader/DATA.PSP` + `data/loader/KEYS.BIN` — bundled plain-ELF POPS
loader and NPDRM version key (from iPoPS, MIT). loader and NPDRM version key (from iPoPS, MIT).
- `data/ps1_templates/` — optional PS1 Classics art templates
(`ICON0.PNG` case frame, `PIC1.PNG` launch background); the app falls back
to drawn art when they're absent.
- State dir (`~/.local/state/psp-preserve/` by default, or `$XDG_STATE_HOME/psp-preserve`): - State dir (`~/.local/state/psp-preserve/` by default, or `$XDG_STATE_HOME/psp-preserve`):
- `library.sqlite` — the catalog + library database. - `library.sqlite` — the catalog + library database.
- `covers/` — downloaded cover cache. - `covers/` — downloaded cover cache.
@@ -203,10 +206,16 @@ PS1 discs (pending) and converted EBOOTs also show up in the **Library** tab
`STARTDAT` PGD), ready to copy to `<PSP>/PSP/GAME/<Game ID>/`. Compatible `STARTDAT` PGD), ready to copy to `<PSP>/PSP/GAME/<Game ID>/`. Compatible
with CFW POPS (ARK-4/ARK-5). with CFW POPS (ARK-4/ARK-5).
- **Artwork** — the converter fetches the game's real cover art (libretro / - **Artwork** — the converter fetches the game's real cover art (libretro /
ScreenScraper / IGDB / Wikipedia, reusing the local cover cache) and bakes ScreenScraper / IGDB / Wikipedia, reusing the local cover cache) and builds
it into the EBOOT's `ICON0` (144×80 XMB icon) and `PIC1` (480×272 launch official-style PS1 Classics art:
background), so the PSP shows the actual game rather than the default PS1 - `ICON0` (80×80) — the cover composited into the bundled case frame
icon. Falls back to the base icon when no art is found. (`data/ps1_templates/ICON0.PNG`) with the PlayStation logo + wordmark;
- `PIC0` (310×180) — a PSN-style info board with the game title, a
description from IGDB, an ESRB rating badge and PS3/PSP platform badges;
- `PIC1` (480×272) — the bundled launch background
(`data/ps1_templates/PIC1.PNG`).
If the template images are missing the app falls back to drawn art, and to
the base icon when no cover is found.
Configure the folders in **Settings → General** (or `PSP_PS1_ROOTS` / Configure the folders in **Settings → General** (or `PSP_PS1_ROOTS` /
`PSP_PS1_OUTPUT_DIR`). Current limits: single-disc games only (no multi-disc `PSP_PS1_OUTPUT_DIR`). Current limits: single-disc games only (no multi-disc
+257 -29
View File
@@ -808,23 +808,63 @@ def compose_game_cover(key_art_bytes):
return bytes(ba) return bytes(ba)
def make_ps1_assets(cover_bytes): _PS1_TEMPLATE_DIR = config.DATA_DIR / "ps1_templates"
"""Build ICON0.PNG (144x80) and PIC1.PNG (480x272) from PS1 cover art.
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 def _ps1_template_bytes(name):
inside the icon frame; PIC1 is the art cropped to fill the launch """Read a bundled PS1 art template (``ICON0.PNG`` / ``PIC1.PNG``)."""
background. 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: try:
from PySide6.QtCore import QBuffer, QByteArray, Qt from PySide6.QtCore import QBuffer, QByteArray, QPointF, QRectF, Qt
from PySide6.QtGui import QImage, QPainter from PySide6.QtGui import (QColor, QFont, QFontMetrics, QImage,
QLinearGradient, QPainter, QPainterPath,
QPen, QPolygonF)
except Exception: # noqa: BLE001 - headless API may lack PySide6 except Exception: # noqa: BLE001 - headless API may lack PySide6
return None, None return None, None, None
art = QImage.fromData(cover_bytes) art = QImage.fromData(cover_bytes)
if art.isNull(): if art.isNull():
return None, None return None, None, None
art = art.convertToFormat(QImage.Format_ARGB32) art = art.convertToFormat(QImage.Format_ARGB32)
def _save(img): def _save(img):
@@ -835,43 +875,215 @@ def make_ps1_assets(cover_bytes):
buf.close() buf.close()
return bytes(ba) return bytes(ba)
# ICON0: fit the cover inside 144x80 (transparent margins). # --- ICON0: cover art inside the case template ---------------------------
icon = QImage(144, 80, QImage.Format_ARGB32) icon = QImage(80, 80, QImage.Format_ARGB32)
icon.fill(Qt.transparent) icon.fill(QColor(0, 0, 0))
p = QPainter(icon) p = QPainter(icon)
scaled = art.scaled(144, 80, Qt.KeepAspectRatio, Qt.SmoothTransformation) p.setRenderHint(QPainter.SmoothPixmapTransform, True)
p.drawImage((144 - scaled.width()) // 2, (80 - scaled.height()) // 2, scaled) 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() 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 = 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) p = QPainter(pic)
scaled = art.scaled(480, 272, Qt.KeepAspectRatioByExpanding, Qt.SmoothTransformation) p.fillRect(0, 0, 480, 272, grad)
p.drawImage((480 - scaled.width()) // 2, (272 - scaled.height()) // 2, scaled) 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() 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): 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 Reuses the local cover cache when available, otherwise searches the PS1
sources (libretro, ScreenScraper, IGDB, Wikipedia). Returns sources (libretro, ScreenScraper, IGDB, Wikipedia). The description and
``(None, None)`` when no art can be produced. 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) key = release_id or stable_key(title)
try: try:
path = fetch_cover(title, key, "ps1", timeout=timeout) path = fetch_cover(title, key, "ps1", timeout=timeout)
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
return None, None return None, None, None
if path is None: if path is None:
return None, None return None, None, None
description = ""
esrb = None
try: 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: except OSError:
return None, None return None, None, None
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -1321,6 +1533,21 @@ def _igdb_date(ts):
return "" 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): def _igdb_game_meta(g):
genres = [x.get("name") for x in (g.get("genres") or []) if isinstance(x, dict) and x.get("name")] genres = [x.get("name") for x in (g.get("genres") or []) if isinstance(x, dict) and x.get("name")]
devs, pubs = [], [] devs, pubs = [], []
@@ -1353,6 +1580,7 @@ def _igdb_game_meta(g):
"genres": list(dict.fromkeys(genres)), "genres": list(dict.fromkeys(genres)),
"players": None, "players": None,
"rating": rating, "rating": rating,
"esrb": _igdb_esrb(g),
"youtube": yt, "youtube": yt,
} }
@@ -1367,7 +1595,7 @@ def _igdb_search(query, limit=20, timeout=10, system="games"):
f'search "{term}"; ' f'search "{term}"; '
"fields name, cover.image_id, first_release_date, summary, genres.name, " "fields name, cover.image_id, first_release_date, summary, genres.name, "
"involved_companies.company.name, involved_companies.developer, involved_companies.publisher, " "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"where platforms = ({platform_id}); "
f"limit {max(limit, 1)};" f"limit {max(limit, 1)};"
) )
@@ -1426,7 +1654,7 @@ def game_metadata_by_id_igdb(game_id, timeout=10):
q = ( q = (
"fields name, cover.image_id, first_release_date, summary, genres.name, " "fields name, cover.image_id, first_release_date, summary, genres.name, "
"involved_companies.company.name, involved_companies.developer, involved_companies.publisher, " "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;" f"where id = {int(game_id)}; limit 1;"
) )
games = _igdb_post(q, timeout) or [] games = _igdb_post(q, timeout) or []
+37 -27
View File
@@ -3366,9 +3366,9 @@ class Ps1ScanWorker(QObject):
class Ps1PreviewWorker(QObject): class Ps1PreviewWorker(QObject):
"""Fetch a disc's cover art and build the EBOOT icon/background preview.""" """Fetch a disc's cover art and build the EBOOT art preview."""
result = Signal(int, str, object, object) # gen, game_id, icon0 bytes, pic1 bytes result = Signal(int, str, object, object, object) # gen, game_id, icon0, pic0, pic1
finished = Signal() finished = Signal()
def __init__(self, gen, title, game_id): def __init__(self, gen, title, game_id):
@@ -3379,10 +3379,10 @@ class Ps1PreviewWorker(QObject):
def run(self): def run(self):
try: try:
icon0, pic1 = covers.ps1_art(self.title, self.game_id) icon0, pic0, pic1 = covers.ps1_art(self.title, self.game_id)
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
icon0, pic1 = None, None icon0, pic0, pic1 = None, None, None
self.result.emit(self.gen, self.game_id, icon0, pic1) self.result.emit(self.gen, self.game_id, icon0, pic0, pic1)
self.finished.emit() self.finished.emit()
@@ -3427,8 +3427,12 @@ class Ps1Page(QWidget):
self.preview_title.setStyleSheet("font-weight: bold;") self.preview_title.setStyleSheet("font-weight: bold;")
self.preview_icon0 = QLabel() self.preview_icon0 = QLabel()
self.preview_icon0.setAlignment(Qt.AlignCenter) self.preview_icon0.setAlignment(Qt.AlignCenter)
self.preview_icon0.setFixedSize(288, 160) self.preview_icon0.setFixedSize(160, 160)
self.preview_icon0.setStyleSheet("background: #14151a; border: 1px solid #333; color: #888;") self.preview_icon0.setStyleSheet("background: #14151a; border: 1px solid #333; color: #888;")
self.preview_pic0 = QLabel()
self.preview_pic0.setAlignment(Qt.AlignCenter)
self.preview_pic0.setFixedSize(310, 180)
self.preview_pic0.setStyleSheet("background: #14151a; border: 1px solid #333; color: #888;")
self.preview_pic1 = QLabel() self.preview_pic1 = QLabel()
self.preview_pic1.setAlignment(Qt.AlignCenter) self.preview_pic1.setAlignment(Qt.AlignCenter)
self.preview_pic1.setFixedSize(480, 272) self.preview_pic1.setFixedSize(480, 272)
@@ -3439,16 +3443,24 @@ class Ps1Page(QWidget):
pv.setContentsMargins(8, 0, 0, 0) pv.setContentsMargins(8, 0, 0, 0)
pv.addWidget(QLabel("<b>Preview</b>")) pv.addWidget(QLabel("<b>Preview</b>"))
pv.addWidget(self.preview_title) pv.addWidget(self.preview_title)
pv.addWidget(QLabel("ICON0 — XMB icon")) pv.addWidget(QLabel("ICON0 — XMB icon (80×80)"))
pv.addWidget(self.preview_icon0) pv.addWidget(self.preview_icon0)
pv.addWidget(QLabel("PIC1 — launch background")) pv.addWidget(QLabel("PIC0 — info board (310×180)"))
pv.addWidget(self.preview_pic0)
pv.addWidget(QLabel("PIC1 — launch background (480×272)"))
pv.addWidget(self.preview_pic1) pv.addWidget(self.preview_pic1)
pv.addStretch(1) pv.addStretch(1)
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setWidget(preview)
scroll.setFrameShape(QScrollArea.NoFrame)
splitter = QSplitter(Qt.Horizontal) splitter = QSplitter(Qt.Horizontal)
splitter.addWidget(self.table) splitter.addWidget(self.table)
splitter.addWidget(preview) splitter.addWidget(scroll)
splitter.setStretchFactor(0, 1) splitter.setStretchFactor(0, 1)
splitter.setSizes([640, 500])
layout.addWidget(splitter, 1) layout.addWidget(splitter, 1)
def _on_preview_changed(self): def _on_preview_changed(self):
@@ -3466,6 +3478,8 @@ class Ps1Page(QWidget):
self.preview_title.setText(e["name"]) self.preview_title.setText(e["name"])
self.preview_icon0.setPixmap(QPixmap()) self.preview_icon0.setPixmap(QPixmap())
self.preview_icon0.setText("Loading…") self.preview_icon0.setText("Loading…")
self.preview_pic0.setPixmap(QPixmap())
self.preview_pic0.setText("Loading…")
self.preview_pic1.setPixmap(QPixmap()) self.preview_pic1.setPixmap(QPixmap())
self.preview_pic1.setText("Loading…") self.preview_pic1.setText("Loading…")
self._preview_gen += 1 self._preview_gen += 1
@@ -3484,34 +3498,30 @@ class Ps1Page(QWidget):
self._preview_threads.append(thread) self._preview_threads.append(thread)
self._preview_workers.append(worker) self._preview_workers.append(worker)
def _on_preview_result(self, gen, game_id, icon0, pic1): def _on_preview_result(self, gen, game_id, icon0, pic0, pic1):
if gen != self._preview_gen: if gen != self._preview_gen:
return return
self.preview_icon0.setText("") self.preview_icon0.setText("")
self.preview_pic0.setText("")
self.preview_pic1.setText("") self.preview_pic1.setText("")
if icon0: for label, data, fallback in (
(self.preview_icon0, icon0, "No art found"),
(self.preview_pic0, pic0, "No art found"),
(self.preview_pic1, pic1, "No art found — the default icon will be used"),
):
if data:
pm = QPixmap() pm = QPixmap()
pm.loadFromData(icon0) pm.loadFromData(data)
self.preview_icon0.setPixmap( label.setPixmap(pm.scaled(label.width(), label.height(),
pm.scaled(self.preview_icon0.width(), self.preview_icon0.height(),
Qt.KeepAspectRatio, Qt.SmoothTransformation)) Qt.KeepAspectRatio, Qt.SmoothTransformation))
else: else:
self.preview_icon0.setText("No art found") label.setText(fallback)
if pic1:
pm = QPixmap()
pm.loadFromData(pic1)
self.preview_pic1.setPixmap(
pm.scaled(self.preview_pic1.width(), self.preview_pic1.height(),
Qt.KeepAspectRatio, Qt.SmoothTransformation))
else:
self.preview_pic1.setText("No art found — the default icon will be used")
def _reset_preview(self, message): def _reset_preview(self, message):
self.preview_title.setText(message) self.preview_title.setText(message)
self.preview_icon0.setPixmap(QPixmap()) for label in (self.preview_icon0, self.preview_pic0, self.preview_pic1):
self.preview_icon0.setText("") label.setPixmap(QPixmap())
self.preview_pic1.setPixmap(QPixmap()) label.setText("")
self.preview_pic1.setText("")
def set_dirs(self, ps1_dir, output_dir): def set_dirs(self, ps1_dir, output_dir):
self._ps1_dir = ps1_dir self._ps1_dir = ps1_dir
+7 -5
View File
@@ -872,6 +872,7 @@ def convert(
cancel_event=None, cancel_event=None,
complevel: int = 1, complevel: int = 1,
icon0: Optional[bytes] = None, icon0: Optional[bytes] = None,
pic0: Optional[bytes] = None,
pic1: Optional[bytes] = None, pic1: Optional[bytes] = None,
) -> Path: ) -> Path:
"""Convert a PS1 disc image to ``out_path`` (an ``EBOOT.PBP``). """Convert a PS1 disc image to ``out_path`` (an ``EBOOT.PBP``).
@@ -880,9 +881,9 @@ def convert(
``.iso``/``.img``/``.bin``. ``base_pbp`` must be a PSN PS1 classic EBOOT. ``.iso``/``.img``/``.bin``. ``base_pbp`` must be a PSN PS1 classic EBOOT.
``complevel`` is the DEFLATE level for the disc data (1-9; 0 = raw, which ``complevel`` is the DEFLATE level for the disc data (1-9; 0 = raw, which
does not boot on real hardware and is only useful for diagnostics). does not boot on real hardware and is only useful for diagnostics).
``icon0`` (144x80 PNG) and ``pic1`` (480x272 PNG) override the generated ``icon0`` (80x80 PNG), ``pic0`` (310x180 PNG) and ``pic1`` (480x272 PNG)
icon/background; when absent the base PBP's ICON0 is reused and PIC1 is override the generated art; when absent the base PBP's ICON0 is reused
left empty. and PIC0/PIC1 are left empty.
""" """
if not base_pbp.is_file(): if not base_pbp.is_file():
raise ConversionError(f"base.pbp not found: {base_pbp}") raise ConversionError(f"base.pbp not found: {base_pbp}")
@@ -916,7 +917,7 @@ def convert(
"sfo": sfo, "sfo": sfo,
"icon0": icon0, "icon0": icon0,
"icon1": b"", "icon1": b"",
"pic0": b"", "pic0": pic0 or b"",
"pic1": pic1 or b"", "pic1": pic1 or b"",
"snd0": b"", "snd0": b"",
"data_psp": data_psp, "data_psp": data_psp,
@@ -986,6 +987,7 @@ def convert_to_folder(
cancel_event=None, cancel_event=None,
complevel: int = 1, complevel: int = 1,
icon0: Optional[bytes] = None, icon0: Optional[bytes] = None,
pic0: Optional[bytes] = None,
pic1: Optional[bytes] = None, pic1: Optional[bytes] = None,
) -> Path: ) -> Path:
"""Convert a PS1 disc into a game folder ready for /PSP/GAME. """Convert a PS1 disc into a game folder ready for /PSP/GAME.
@@ -1000,7 +1002,7 @@ def convert_to_folder(
convert(source, title, game_id, base_pbp, folder / "EBOOT.PBP", convert(source, title, game_id, base_pbp, folder / "EBOOT.PBP",
progress_cb=progress_cb, cancel_event=cancel_event, complevel=complevel, progress_cb=progress_cb, cancel_event=cancel_event, complevel=complevel,
icon0=icon0, pic1=pic1) icon0=icon0, pic0=pic0, pic1=pic1)
return folder return folder
+4 -4
View File
@@ -240,17 +240,17 @@ class Ps1ConvertSource(Source):
from . import covers, ps1_convert from . import covers, ps1_convert
# Pull the game's real cover art (libretro / ScreenScraper / IGDB / # Pull the game's real cover art (libretro / ScreenScraper / IGDB /
# Wikipedia) and bake it into ICON0 + PIC1 so the XMB shows the actual # Wikipedia) and bake it into ICON0 + PIC0 + PIC1 so the XMB shows the
# game, not the default PS1 icon. Best effort: fall back to defaults. # official-style art, not the default PS1 icon. Best effort.
if progress_cb is not None: if progress_cb is not None:
progress_cb("Artwork", 0.0, "") progress_cb("Artwork", 0.0, "")
icon0, pic1 = covers.ps1_art(title, game_id) icon0, pic0, pic1 = covers.ps1_art(title, game_id)
try: try:
ps1_convert.convert_to_folder( ps1_convert.convert_to_folder(
source, title, game_id, self.base_pbp, dest_dir, source, title, game_id, self.base_pbp, dest_dir,
progress_cb=cb, cancel_event=cancel_event, progress_cb=cb, cancel_event=cancel_event,
icon0=icon0, pic1=pic1, icon0=icon0, pic0=pic0, pic1=pic1,
) )
except ps1_convert.Cancelled: except ps1_convert.Cancelled:
raise TransferCancelled() from None raise TransferCancelled() from None