diff --git a/README.md b/README.md index b0525b5..97515b2 100644 --- a/README.md +++ b/README.md @@ -202,6 +202,11 @@ PS1 discs (pending) and converted EBOOTs also show up in the **Library** tab compressed PSAR) plus a `KEYS.BIN` (NPDRM version key used to decrypt the `STARTDAT` PGD), ready to copy to `/PSP/GAME//`. Compatible with CFW POPS (ARK-4/ARK-5). +- **Artwork** — the converter fetches the game's real cover art (libretro / + ScreenScraper / IGDB / Wikipedia, reusing the local cover cache) and bakes + it into the EBOOT's `ICON0` (144×80 XMB icon) and `PIC1` (480×272 launch + background), so the PSP shows the actual game rather than the default PS1 + icon. Falls back to the base icon when no art is found. Configure the folders in **Settings → General** (or `PSP_PS1_ROOTS` / `PSP_PS1_OUTPUT_DIR`). Current limits: single-disc games only (no multi-disc diff --git a/app/covers.py b/app/covers.py index 24970ac..0144a78 100644 --- a/app/covers.py +++ b/app/covers.py @@ -808,6 +808,71 @@ 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. + + 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. + """ + try: + from PySide6.QtCore import QBuffer, QByteArray, Qt + from PySide6.QtGui import QImage, QPainter + except Exception: # noqa: BLE001 - headless API may lack PySide6 + return None, None + + art = QImage.fromData(cover_bytes) + if art.isNull(): + return None, None + art = art.convertToFormat(QImage.Format_ARGB32) + + def _save(img): + ba = QByteArray() + buf = QBuffer(ba) + buf.open(QBuffer.WriteOnly) + img.save(buf, "PNG") + buf.close() + return bytes(ba) + + # ICON0: fit the cover inside 144x80 (transparent margins). + icon = QImage(144, 80, QImage.Format_ARGB32) + icon.fill(Qt.transparent) + p = QPainter(icon) + scaled = art.scaled(144, 80, Qt.KeepAspectRatio, Qt.SmoothTransformation) + p.drawImage((144 - scaled.width()) // 2, (80 - scaled.height()) // 2, scaled) + p.end() + + # PIC1: crop the cover to fill 480x272. + pic = QImage(480, 272, QImage.Format_ARGB32) + pic.fill(Qt.transparent) + p = QPainter(pic) + scaled = art.scaled(480, 272, Qt.KeepAspectRatioByExpanding, Qt.SmoothTransformation) + p.drawImage((480 - scaled.width()) // 2, (272 - scaled.height()) // 2, scaled) + p.end() + + return _save(icon), _save(pic) + + +def ps1_art(title, release_id, timeout=10): + """Fetch a PS1 game's cover and return ``(icon0, 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. + """ + try: + path = fetch_cover(title, release_id, "ps1", timeout=timeout) + except Exception: # noqa: BLE001 + return None, None + if path is None: + return None, None + try: + return make_ps1_assets(path.read_bytes()) + except OSError: + return None, None + + # --------------------------------------------------------------------------- # # Public entry point # --------------------------------------------------------------------------- # diff --git a/app/ps1_convert.py b/app/ps1_convert.py index a23b18a..3fb1671 100644 --- a/app/ps1_convert.py +++ b/app/ps1_convert.py @@ -871,6 +871,8 @@ def convert( progress_cb=None, cancel_event=None, complevel: int = 1, + icon0: Optional[bytes] = None, + pic1: Optional[bytes] = None, ) -> Path: """Convert a PS1 disc image to ``out_path`` (an ``EBOOT.PBP``). @@ -878,6 +880,9 @@ def convert( ``.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 does not boot on real hardware and is only useful for diagnostics). + ``icon0`` (144x80 PNG) and ``pic1`` (480x272 PNG) override the generated + icon/background; when absent the base PBP's ICON0 is reused and PIC1 is + left empty. """ if not base_pbp.is_file(): raise ConversionError(f"base.pbp not found: {base_pbp}") @@ -901,11 +906,10 @@ def convert( # Reuse the base PBP's SFO, patched with our title + game id (the # popstation approach), rather than rebuilding it from scratch. sfo = patch_sfo(extract_sfo(base_pbp), title, game_id) - # Reuse the base PBP's ICON0 too. The XMB icon parser is picky about PNG - # variants, and the base icon is guaranteed to decode; the other image - # sections are left empty exactly like popstation when no assets are - # supplied alongside the conversion. - icon0 = extract_icon0(base_pbp) + # Reuse the base PBP's ICON0 when no custom icon was supplied (the XMB + # icon parser is picky about PNG variants, and the base icon is guaranteed + # to decode). PIC1 is the launch background; empty when no art is given. + icon0 = icon0 or extract_icon0(base_pbp) # Sections in header order; empty sections keep the previous offset. sections = { @@ -913,7 +917,7 @@ def convert( "icon0": icon0, "icon1": b"", "pic0": b"", - "pic1": b"", + "pic1": pic1 or b"", "snd0": b"", "data_psp": data_psp, # psar written separately, sized after everything else. @@ -981,6 +985,8 @@ def convert_to_folder( progress_cb=None, cancel_event=None, complevel: int = 1, + icon0: Optional[bytes] = None, + pic1: Optional[bytes] = None, ) -> Path: """Convert a PS1 disc into a game folder ready for /PSP/GAME. @@ -993,7 +999,8 @@ def convert_to_folder( folder.mkdir(parents=True, exist_ok=True) 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) return folder diff --git a/app/transfers.py b/app/transfers.py index 2a711c2..98f2192 100644 --- a/app/transfers.py +++ b/app/transfers.py @@ -237,12 +237,20 @@ class Ps1ConvertSource(Source): if progress_cb is not None and total: progress_cb("Converting", min(done / total, 1.0), "") - from . import ps1_convert + from . import covers, ps1_convert + + # Pull the game's real cover art (libretro / ScreenScraper / IGDB / + # Wikipedia) and bake it into ICON0 + PIC1 so the XMB shows the actual + # game, not the default PS1 icon. Best effort: fall back to defaults. + if progress_cb is not None: + progress_cb("Artwork", 0.0, "") + icon0, pic1 = covers.ps1_art(title, game_id) try: ps1_convert.convert_to_folder( source, title, game_id, self.base_pbp, dest_dir, progress_cb=cb, cancel_event=cancel_event, + icon0=icon0, pic1=pic1, ) except ps1_convert.Cancelled: raise TransferCancelled() from None