From 77980a79ec303d92d8c77fd32c8b3e40847f4b7c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 22 Jun 2026 21:45:39 +0000 Subject: [PATCH] feat(v2): interactive 3D box art on game detail hero MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an RBox3D primitive that builds a rotatable, fake-3D game box from three flat ScreenScraper scans (front, back, spine) using CSS 3D transforms. Box proportions derive from the images themselves; it rotates via pointer drag, arrow keys / gamepad D-pad, and the right analog stick, drifts gently when idle, and honours prefers-reduced-motion. The game detail hero (CoverColumn) upgrades to the spinning box when the "3D box" boxart style is selected and the rom has the full set of faces, falling back to the flat cover otherwise. Backend: persist the box-2D-side (spine) scan locally, mirroring the existing box-2D-back handling — new BOX2D_SIDE media type + box2d_side_path on ss_metadata, opt-in via scan.media. - RBox3D primitive + Storybook story (controls + keyboard-rotation play()) - useBoxFaces composable resolving the three faces + a `complete` gate - box3d-alt i18n key across all locales - backend BOX2D_SIDE persistence + tests Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_019itLXRfJXGGbhPY3JyqnuN --- backend/config/config_manager.py | 1 + backend/handler/metadata/ss_handler.py | 6 + .../tests/handler/metadata/test_ss_handler.py | 80 ++++ examples/config.example.yml | 1 + .../src/__generated__/models/RomSSMetadata.ts | 1 + frontend/src/locales/bg_BG/rom.json | 1 + frontend/src/locales/cs_CZ/rom.json | 1 + frontend/src/locales/de_DE/rom.json | 1 + frontend/src/locales/en_GB/rom.json | 1 + frontend/src/locales/en_US/rom.json | 1 + frontend/src/locales/es_ES/rom.json | 1 + frontend/src/locales/fr_FR/rom.json | 1 + frontend/src/locales/hu_HU/rom.json | 1 + frontend/src/locales/it_IT/rom.json | 1 + frontend/src/locales/ja_JP/rom.json | 1 + frontend/src/locales/ko_KR/rom.json | 1 + frontend/src/locales/pl_PL/rom.json | 1 + frontend/src/locales/pt_BR/rom.json | 1 + frontend/src/locales/ro_RO/rom.json | 1 + frontend/src/locales/ru_RU/rom.json | 1 + frontend/src/locales/zh_CN/rom.json | 1 + frontend/src/locales/zh_TW/rom.json | 1 + .../v2/components/GameDetails/CoverColumn.vue | 37 +- .../src/v2/composables/useBoxFaces/index.ts | 73 +++ .../useBoxFaces/useBoxFaces.test.ts | 81 ++++ frontend/src/v2/lib/index.ts | 1 + .../src/v2/lib/media/RBox3D/RBox3D.stories.ts | 101 ++++ frontend/src/v2/lib/media/RBox3D/RBox3D.vue | 431 ++++++++++++++++++ frontend/src/v2/lib/media/RBox3D/index.ts | 1 + 29 files changed, 830 insertions(+), 1 deletion(-) create mode 100644 frontend/src/v2/composables/useBoxFaces/index.ts create mode 100644 frontend/src/v2/composables/useBoxFaces/useBoxFaces.test.ts create mode 100644 frontend/src/v2/lib/media/RBox3D/RBox3D.stories.ts create mode 100644 frontend/src/v2/lib/media/RBox3D/RBox3D.vue create mode 100644 frontend/src/v2/lib/media/RBox3D/index.ts diff --git a/backend/config/config_manager.py b/backend/config/config_manager.py index d1cfc842f..80ea2068c 100644 --- a/backend/config/config_manager.py +++ b/backend/config/config_manager.py @@ -73,6 +73,7 @@ class MetadataMediaType(enum.StrEnum): BEZEL = "bezel" BOX2D = "box2d" BOX2D_BACK = "box2d_back" + BOX2D_SIDE = "box2d_side" BOX3D = "box3d" MIXIMAGE = "miximage" MIXIMAGE_V2 = "miximage_v2" diff --git a/backend/handler/metadata/ss_handler.py b/backend/handler/metadata/ss_handler.py index 2047f08cf..1e9f4387c 100644 --- a/backend/handler/metadata/ss_handler.py +++ b/backend/handler/metadata/ss_handler.py @@ -183,6 +183,7 @@ class SSMetadataMedia(TypedDict): # Resources stored in filesystem bezel_path: str | None box2d_back_path: str | None + box2d_side_path: str | None box3d_path: str | None fanart_path: str | None miximage_path: str | None @@ -244,6 +245,7 @@ def extract_media_from_ss_game(rom: Rom, game: SSGame) -> SSMetadataMedia: video_normalized_url=None, bezel_path=None, box2d_back_path=None, + box2d_side_path=None, box3d_path=None, fanart_path=None, miximage_path=None, @@ -358,6 +360,10 @@ def extract_media_from_ss_game(rom: Rom, game: SSGame) -> SSMetadataMedia: ss_media["box2d_side_url"] = strip_sensitive_query_params( media["url"], SENSITIVE_KEYS ) + if MetadataMediaType.BOX2D_SIDE in preferred_media_types: + ss_media["box2d_side_path"] = ( + f"{fs_resource_handler.get_media_resources_path(rom.platform_id, rom.id, MetadataMediaType.BOX2D_SIDE)}/box2d_side.png" + ) elif media.get("type") == "steamgrid" and not ss_media["steamgrid_url"]: ss_media["steamgrid_url"] = strip_sensitive_query_params( media["url"], SENSITIVE_KEYS diff --git a/backend/tests/handler/metadata/test_ss_handler.py b/backend/tests/handler/metadata/test_ss_handler.py index f24f995e9..a816553da 100644 --- a/backend/tests/handler/metadata/test_ss_handler.py +++ b/backend/tests/handler/metadata/test_ss_handler.py @@ -208,6 +208,86 @@ class TestExtractMediaFromSsGame: assert result["box2d_url"] is not None assert "box-2D(us)" in result["box2d_url"] + def _make_game_with_box_faces(self) -> SSGame: + """A game exposing all three box faces: front, back and spine.""" + return cast( + SSGame, + { + "medias": [ + { + "type": "box-2D", + "parent": "jeu", + "region": "us", + "url": "https://screenscraper.example.com/box-2D", + "crc": "aabbccdd", + "md5": "deadbeef", + "sha1": "cafebabe", + "size": "12345", + "format": "png", + }, + { + "type": "box-2D-back", + "parent": "jeu", + "region": "us", + "url": "https://screenscraper.example.com/box-2D-back", + "crc": "11223344", + "md5": "feedface", + "sha1": "baadf00d", + "size": "23456", + "format": "png", + }, + { + "type": "box-2D-side", + "parent": "jeu", + "region": "us", + "url": "https://screenscraper.example.com/box-2D-side", + "crc": "55667788", + "md5": "0ddba11", + "sha1": "f00dcafe", + "size": "34567", + "format": "png", + }, + ] + }, + ) + + def test_box2d_side_path_set_when_in_config(self): + """When 'box2d_side' is in SCAN_MEDIA the spine is persisted locally.""" + config = _make_config(scan_media=["box2d", "box2d_back", "box2d_side"]) + rom = self._make_rom() + game = self._make_game_with_box_faces() + + with ( + patch("handler.metadata.ss_handler.cm.get_config", return_value=config), + patch( + "handler.metadata.ss_handler.fs_resource_handler.get_media_resources_path", + side_effect=lambda pid, rid, mt: f"roms/{pid}/{rid}/{mt.value}", + ), + ): + result = extract_media_from_ss_game(rom, game) + + assert result["box2d_side_url"] is not None + assert "box-2D-side" in result["box2d_side_url"] + assert result["box2d_side_path"] == "roms/1/100/box2d_side/box2d_side.png" + + def test_box2d_side_path_not_set_when_absent_from_config(self): + """Without 'box2d_side' in SCAN_MEDIA the spine URL is kept but not stored.""" + config = _make_config(scan_media=["box2d", "box2d_back"]) + rom = self._make_rom() + game = self._make_game_with_box_faces() + + with ( + patch("handler.metadata.ss_handler.cm.get_config", return_value=config), + patch( + "handler.metadata.ss_handler.fs_resource_handler.get_media_resources_path", + side_effect=lambda pid, rid, mt: f"roms/{pid}/{rid}/{mt.value}", + ), + ): + result = extract_media_from_ss_game(rom, game) + + assert result["box2d_side_url"] is not None + assert result["box2d_side_path"] is None + def _make_game_with_both_miximage_versions(self) -> SSGame: """A game that has both mixrbv1 and mixrbv2 (v1 listed first, matching SS API order).""" return cast( diff --git a/examples/config.example.yml b/examples/config.example.yml index 48a896813..8d539c0f0 100644 --- a/examples/config.example.yml +++ b/examples/config.example.yml @@ -133,6 +133,7 @@ # - video_normalized # Normalized video (smaller file size than video) # # Media used for batocera gamelist.xml export # - box2d_back # Back cover art +# - box2d_side # Box spine (enables the interactive 3D box on detail pages) # - logo # Transparent logo # # Other media assets (might be used in the future) # - marquee # Custom marquee diff --git a/frontend/src/__generated__/models/RomSSMetadata.ts b/frontend/src/__generated__/models/RomSSMetadata.ts index de54834e5..d0377f04b 100644 --- a/frontend/src/__generated__/models/RomSSMetadata.ts +++ b/frontend/src/__generated__/models/RomSSMetadata.ts @@ -24,6 +24,7 @@ export type RomSSMetadata = { video_normalized_url?: (string | null); bezel_path?: (string | null); box2d_back_path?: (string | null); + box2d_side_path?: (string | null); box3d_path?: (string | null); fanart_path?: (string | null); miximage_path?: (string | null); diff --git a/frontend/src/locales/bg_BG/rom.json b/frontend/src/locales/bg_BG/rom.json index 85e837638..1f4a6bfc5 100644 --- a/frontend/src/locales/bg_BG/rom.json +++ b/frontend/src/locales/bg_BG/rom.json @@ -8,6 +8,7 @@ "age-rating": "Възрастова оценка", "all-styles": "Всички стилове", "backlogged": "В списъка", + "box3d-alt": "Въртяща се 3D кутия за {title}", "by": "от", "cant-copy-link": "Линкът не може да бъде копиран, копирай го ръчно", "collections": "Колекции", diff --git a/frontend/src/locales/cs_CZ/rom.json b/frontend/src/locales/cs_CZ/rom.json index 69c0d8af6..03b8263dc 100644 --- a/frontend/src/locales/cs_CZ/rom.json +++ b/frontend/src/locales/cs_CZ/rom.json @@ -8,6 +8,7 @@ "age-rating": "Hodnocení věku", "all-styles": "Všechny styly", "backlogged": "Odloženo", + "box3d-alt": "Otočná 3D krabice pro {title}", "by": "od", "cant-copy-link": "Nelze zkopírovat odkaz do schránky, zkopírujte ručně", "collections": "Kolekce", diff --git a/frontend/src/locales/de_DE/rom.json b/frontend/src/locales/de_DE/rom.json index a6c658e85..32cb1c630 100644 --- a/frontend/src/locales/de_DE/rom.json +++ b/frontend/src/locales/de_DE/rom.json @@ -8,6 +8,7 @@ "age-rating": "Altersfreigabe", "all-styles": "Alle Stile", "backlogged": "Vorgemerkt", + "box3d-alt": "Drehbares 3D-Cover von {title}", "by": "nach", "cant-copy-link": "Link kann nicht in Zwischenablage kopiert werden. Bitte manuell kopieren.", "collections": "Sammlungen", diff --git a/frontend/src/locales/en_GB/rom.json b/frontend/src/locales/en_GB/rom.json index f5d56bfae..b8f431220 100644 --- a/frontend/src/locales/en_GB/rom.json +++ b/frontend/src/locales/en_GB/rom.json @@ -8,6 +8,7 @@ "age-rating": "Age rating", "all-styles": "All Styles", "backlogged": "Backlogged", + "box3d-alt": "Rotatable 3D box art for {title}", "by": "by", "cant-copy-link": "Can't copy link to clipboard, copy it manually", "collections": "Collections", diff --git a/frontend/src/locales/en_US/rom.json b/frontend/src/locales/en_US/rom.json index c1f33720c..f2532066c 100644 --- a/frontend/src/locales/en_US/rom.json +++ b/frontend/src/locales/en_US/rom.json @@ -8,6 +8,7 @@ "age-rating": "Age rating", "all-styles": "All Styles", "backlogged": "Backlogged", + "box3d-alt": "Rotatable 3D box art for {title}", "by": "by", "cant-copy-link": "Can't copy link to clipboard, copy it manually", "collections": "Collections", diff --git a/frontend/src/locales/es_ES/rom.json b/frontend/src/locales/es_ES/rom.json index 13eafd443..cd5c79854 100644 --- a/frontend/src/locales/es_ES/rom.json +++ b/frontend/src/locales/es_ES/rom.json @@ -8,6 +8,7 @@ "age-rating": "Clasificación por edad", "all-styles": "Todos los Estilos", "backlogged": "Pendiente", + "box3d-alt": "Carátula 3D giratoria de {title}", "by": "por", "cant-copy-link": "No se pudo copiar el link al portapapeles, copialo manualmente", "collections": "Colecciones", diff --git a/frontend/src/locales/fr_FR/rom.json b/frontend/src/locales/fr_FR/rom.json index 496c05ee4..795c525d6 100644 --- a/frontend/src/locales/fr_FR/rom.json +++ b/frontend/src/locales/fr_FR/rom.json @@ -8,6 +8,7 @@ "age-rating": "Classification par âge", "all-styles": "Tous les Styles", "backlogged": "En attente", + "box3d-alt": "Boîtier 3D pivotant de {title}", "by": "par", "cant-copy-link": "Impossible de copier le lien dans le presse-papiers, copiez-le manuellement", "collections": "Collections", diff --git a/frontend/src/locales/hu_HU/rom.json b/frontend/src/locales/hu_HU/rom.json index cd1da0afd..d72339915 100644 --- a/frontend/src/locales/hu_HU/rom.json +++ b/frontend/src/locales/hu_HU/rom.json @@ -8,6 +8,7 @@ "age-rating": "Korhatár", "all-styles": "Minden stílus", "backlogged": "Függőben", + "box3d-alt": "Forgatható 3D doboz: {title}", "by": "általa", "cant-copy-link": "A linket nem lehet a vágólapra másolni, kézzel kell.", "collections": "Gyűjtemények", diff --git a/frontend/src/locales/it_IT/rom.json b/frontend/src/locales/it_IT/rom.json index 984e539e5..39e2dfc17 100644 --- a/frontend/src/locales/it_IT/rom.json +++ b/frontend/src/locales/it_IT/rom.json @@ -8,6 +8,7 @@ "age-rating": "Classificazione età", "all-styles": "Tutti gli Stili", "backlogged": "In attesa", + "box3d-alt": "Confezione 3D ruotabile di {title}", "by": "di", "cant-copy-link": "Impossibile copiare il link negli appunti, copialo manualmente", "collections": "Collezioni", diff --git a/frontend/src/locales/ja_JP/rom.json b/frontend/src/locales/ja_JP/rom.json index bc25497e6..3af098cc9 100644 --- a/frontend/src/locales/ja_JP/rom.json +++ b/frontend/src/locales/ja_JP/rom.json @@ -8,6 +8,7 @@ "age-rating": "レーティング", "all-styles": "全スタイル", "backlogged": "未処理", + "box3d-alt": "{title} の回転式3Dボックスアート", "by": "by", "cant-copy-link": "クリップボードへのコピー失敗 手動でコピーしてください", "collections": "コレクション", diff --git a/frontend/src/locales/ko_KR/rom.json b/frontend/src/locales/ko_KR/rom.json index eede93702..f69330a07 100644 --- a/frontend/src/locales/ko_KR/rom.json +++ b/frontend/src/locales/ko_KR/rom.json @@ -8,6 +8,7 @@ "age-rating": "연령 제한", "all-styles": "모든 스타일", "backlogged": "나중에 플레이", + "box3d-alt": "{title}의 회전 가능한 3D 박스 아트", "by": "분류", "cant-copy-link": "링크를 클립보드에 복사할 수 없습니다. 수동으로 복사합니다", "collections": "시리즈", diff --git a/frontend/src/locales/pl_PL/rom.json b/frontend/src/locales/pl_PL/rom.json index 2c65a9f39..35acce1ad 100644 --- a/frontend/src/locales/pl_PL/rom.json +++ b/frontend/src/locales/pl_PL/rom.json @@ -8,6 +8,7 @@ "age-rating": "Kategoria wiekowa", "all-styles": "Wszystkie Style", "backlogged": "Zaległe", + "box3d-alt": "Obracane pudełko 3D dla {title}", "by": "przez", "cant-copy-link": "Nie można skopiować linku do schowka, skopiuj go ręcznie", "collections": "Kolekcje", diff --git a/frontend/src/locales/pt_BR/rom.json b/frontend/src/locales/pt_BR/rom.json index 99c11d60c..7277b3279 100644 --- a/frontend/src/locales/pt_BR/rom.json +++ b/frontend/src/locales/pt_BR/rom.json @@ -8,6 +8,7 @@ "age-rating": "Classificação etária", "all-styles": "Todos os Estilos", "backlogged": "Em espera", + "box3d-alt": "Caixa 3D giratória de {title}", "by": "por", "cant-copy-link": "Não é possível copiar o link para a área de transferência, copie manualmente", "collections": "Coleções", diff --git a/frontend/src/locales/ro_RO/rom.json b/frontend/src/locales/ro_RO/rom.json index f83330134..b330533ae 100644 --- a/frontend/src/locales/ro_RO/rom.json +++ b/frontend/src/locales/ro_RO/rom.json @@ -8,6 +8,7 @@ "age-rating": "Clasificare pe vârstă", "all-styles": "Toate Stilurile", "backlogged": "În așteptare", + "box3d-alt": "Cutie 3D rotativă pentru {title}", "by": "de", "cant-copy-link": "Nu s-a putut copia linkul în clipboard, copiază-l manual", "collections": "Colecții", diff --git a/frontend/src/locales/ru_RU/rom.json b/frontend/src/locales/ru_RU/rom.json index 96a70b652..87f10c697 100644 --- a/frontend/src/locales/ru_RU/rom.json +++ b/frontend/src/locales/ru_RU/rom.json @@ -8,6 +8,7 @@ "age-rating": "Возрастной рейтинг", "all-styles": "Все Стили", "backlogged": "Отложено", + "box3d-alt": "Вращающаяся 3D-коробка для {title}", "by": "от", "cant-copy-link": "Не удается скопировать ссылку в буфер обмена, скопируйте ее вручную", "collections": "Коллекции", diff --git a/frontend/src/locales/zh_CN/rom.json b/frontend/src/locales/zh_CN/rom.json index b480d5468..e312b7628 100644 --- a/frontend/src/locales/zh_CN/rom.json +++ b/frontend/src/locales/zh_CN/rom.json @@ -8,6 +8,7 @@ "age-rating": "年龄分级", "all-styles": "所有风格", "backlogged": "待办", + "box3d-alt": "{title} 的可旋转 3D 包装盒", "by": "根据", "cant-copy-link": "无法将链接复制到剪贴板,请手动复制", "collections": "收藏", diff --git a/frontend/src/locales/zh_TW/rom.json b/frontend/src/locales/zh_TW/rom.json index a085227e7..a39faedf5 100644 --- a/frontend/src/locales/zh_TW/rom.json +++ b/frontend/src/locales/zh_TW/rom.json @@ -8,6 +8,7 @@ "age-rating": "年龄分级", "all-styles": "所有風格", "backlogged": "待遊玩", + "box3d-alt": "{title} 的可旋轉 3D 包裝盒", "by": "依據", "cant-copy-link": "無法複製下載鏈接到剪貼簿,請手動複製", "collections": "收藏庫", diff --git a/frontend/src/v2/components/GameDetails/CoverColumn.vue b/frontend/src/v2/components/GameDetails/CoverColumn.vue index 0f3dc15ee..88328d85e 100644 --- a/frontend/src/v2/components/GameDetails/CoverColumn.vue +++ b/frontend/src/v2/components/GameDetails/CoverColumn.vue @@ -4,20 +4,55 @@ // the procedural placeholder when empty, and is the destination of the // shared-element morph from the GameCard the user clicked through from — // all of that lives in GameCover now; this just sizes the column. +// +// When the gallery boxart style is the 3D box AND the rom has the full set +// of flat scans (front + back + spine, from ScreenScraper), the hero +// upgrades to the interactive RBox3D the user can spin. Anything missing — +// a different style, an incomplete set, or a failed image — falls straight +// back to the flat GameCover. +import { computed, ref } from "vue"; +import { useI18n } from "vue-i18n"; +import { RBox3D } from "@v2/lib"; +import { useUISettings } from "@/composables/useUISettings"; import type { DetailedRom } from "@/stores/roms"; +import { useBoxFaces } from "@/v2/composables/useBoxFaces"; import GameCover from "@/v2/components/shared/GameCover.vue"; defineOptions({ inheritAttrs: false }); -defineProps<{ +const props = defineProps<{ rom: DetailedRom; alt: string; }>(); + +const { t } = useI18n(); +const { boxartStyle } = useUISettings(); +const faces = useBoxFaces(() => props.rom); +const box3dFailed = ref(false); + +// Resolved faces, or null when the interactive box can't / shouldn't render. +// Returning the concrete object keeps the template free of non-null asserts. +const box3d = computed(() => { + if (boxartStyle.value !== "box3d_path" || box3dFailed.value) return null; + const f = faces.value; + if (!f.complete || !f.front || !f.back || !f.spine) return null; + return { front: f.front, back: f.back, spine: f.spine }; +});