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:
@@ -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/
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user