From dd4f22767fcb81a09f2ae93041f86bc35040d394 Mon Sep 17 00:00:00 2001 From: "Calum H." Date: Sat, 24 May 2025 18:50:51 +0100 Subject: [PATCH] polish: use indexed db to persist skin previews --- apps/app-frontend/src/App.vue | 10 ++ .../helpers/rendering/batch-skin-renderer.ts | 34 ++++- .../helpers/storage/skin-preview-storage.ts | 118 ++++++++++++++++++ 3 files changed, 161 insertions(+), 1 deletion(-) create mode 100644 apps/app-frontend/src/helpers/storage/skin-preview-storage.ts diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue index 2920209f2..d6e3ddb05 100644 --- a/apps/app-frontend/src/App.vue +++ b/apps/app-frontend/src/App.vue @@ -69,6 +69,8 @@ import { hide_ads_window, init_ads_window } from '@/helpers/ads.js' import FriendsList from '@/components/ui/friends/FriendsList.vue' import { openUrl } from '@tauri-apps/plugin-opener' import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue' +import { get_available_capes, get_available_skins } from './helpers/skins' +import { generateSkinPreviews } from './helpers/rendering/batch-skin-renderer' const formatRelativeTime = useRelativeTime() @@ -198,6 +200,14 @@ async function setupApp() { get_opening_command().then(handleCommand) checkUpdates() fetchCredentials() + + try { + const skins = (await get_available_skins()) ?? [] + const capes = (await get_available_capes()) ?? [] + generateSkinPreviews(skins, capes) + } catch (error) { + console.warn("Failed to generate skin previews in app setup.", error); + } } const stateFailed = ref(false) diff --git a/apps/app-frontend/src/helpers/rendering/batch-skin-renderer.ts b/apps/app-frontend/src/helpers/rendering/batch-skin-renderer.ts index 540043cd0..c269d0699 100644 --- a/apps/app-frontend/src/helpers/rendering/batch-skin-renderer.ts +++ b/apps/app-frontend/src/helpers/rendering/batch-skin-renderer.ts @@ -3,6 +3,7 @@ import type { Skin, Cape } from '../skins' import { determineModelType } from '../skins' import { reactive } from 'vue' import { setupSkinModel, disposeCaches } from '@modrinth/utils' +import { skinPreviewStorage } from '../storage/skin-preview-storage' export interface RenderResult { forwards: string @@ -98,7 +99,6 @@ class BatchSkinRenderer { this.scene.remove(this.currentModel) } - // Use the utility function to setup the skin model const { model } = await setupSkinModel(modelUrl, textureUrl, capeModelUrl, capeUrl) const group = new THREE.Group() @@ -130,6 +130,21 @@ function getModelUrlForVariant(variant: string): string { export const map = reactive(new Map()) const DEBUG_MODE = false +export async function cleanupUnusedPreviews(skins: Skin[], capes: Cape[]): Promise { + const validKeys = new Set() + + for (const skin of skins) { + const key = `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}` + validKeys.add(key) + } + + try { + await skinPreviewStorage.cleanupInvalidKeys(validKeys) + } catch (error) { + console.warn('Failed to cleanup unused skin previews:', error) + } +} + export async function generateSkinPreviews(skins: Skin[], capes: Cape[]): Promise { const renderer = new BatchSkinRenderer() const capeModelUrl = '/src/assets/models/cape.gltf' @@ -147,6 +162,16 @@ export async function generateSkinPreviews(skins: Skin[], capes: Cape[]): Promis } else continue } + try { + const cached = await skinPreviewStorage.retrieve(key) + if (cached) { + map.set(key, cached) + continue + } + } catch (error) { + console.warn('Failed to retrieve cached skin preview:', error) + } + let variant = skin.variant if (variant === 'UNKNOWN') { try { @@ -167,8 +192,15 @@ export async function generateSkinPreviews(skins: Skin[], capes: Cape[]): Promis ) map.set(key, renderResult) + + try { + await skinPreviewStorage.store(key, renderResult) + } catch (error) { + console.warn('Failed to store skin preview in persistent storage:', error) + } } } finally { renderer.dispose() + await cleanupUnusedPreviews(skins, capes) } } diff --git a/apps/app-frontend/src/helpers/storage/skin-preview-storage.ts b/apps/app-frontend/src/helpers/storage/skin-preview-storage.ts new file mode 100644 index 000000000..b74c6023a --- /dev/null +++ b/apps/app-frontend/src/helpers/storage/skin-preview-storage.ts @@ -0,0 +1,118 @@ +import type { RenderResult } from '../rendering/batch-skin-renderer' + +interface StoredPreview { + forwards: Blob + backwards: Blob + timestamp: number +} + +export class SkinPreviewStorage { + private dbName = 'skin-previews' + private version = 1 + private db: IDBDatabase | null = null + + async init(): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(this.dbName, this.version) + + request.onerror = () => reject(request.error) + request.onsuccess = () => { + this.db = request.result + resolve() + } + + request.onupgradeneeded = () => { + const db = request.result + if (!db.objectStoreNames.contains('previews')) { + db.createObjectStore('previews') + } + } + }) + } + + async store(key: string, result: RenderResult): Promise { + if (!this.db) await this.init() + + const transaction = this.db!.transaction(['previews'], 'readwrite') + const store = transaction.objectStore('previews') + + const forwardsBlob = await fetch(result.forwards).then(r => r.blob()) + const backwardsBlob = await fetch(result.backwards).then(r => r.blob()) + + const storedPreview: StoredPreview = { + forwards: forwardsBlob, + backwards: backwardsBlob, + timestamp: Date.now() + } + + return new Promise((resolve, reject) => { + const request = store.put(storedPreview, key) + + request.onsuccess = () => resolve() + request.onerror = () => reject(request.error) + }) + } + + async retrieve(key: string): Promise { + if (!this.db) await this.init() + + const transaction = this.db!.transaction(['previews'], 'readonly') + const store = transaction.objectStore('previews') + + return new Promise((resolve, reject) => { + const request = store.get(key) + + request.onsuccess = () => { + const result = request.result as StoredPreview | undefined + + if (!result) { + resolve(null) + return + } + + const forwards = URL.createObjectURL(result.forwards) + const backwards = URL.createObjectURL(result.backwards) + resolve({ forwards, backwards }) + } + request.onerror = () => reject(request.error) + }) + } + + async cleanupInvalidKeys(validKeys: Set): Promise { + if (!this.db) await this.init() + + const transaction = this.db!.transaction(['previews'], 'readwrite') + const store = transaction.objectStore('previews') + let deletedCount = 0 + + return new Promise((resolve, reject) => { + const request = store.openCursor() + + request.onsuccess = (event) => { + const cursor = (event.target as IDBRequest).result + + if (cursor) { + const key = cursor.primaryKey as string + + if (!validKeys.has(key)) { + const deleteRequest = cursor.delete() + deleteRequest.onsuccess = () => { + deletedCount++ + } + deleteRequest.onerror = () => { + console.warn('Failed to delete invalid entry:', key) + } + } + + cursor.continue() + } else { + resolve(deletedCount) + } + } + + request.onerror = () => reject(request.error) + }) + } +} + +export const skinPreviewStorage = new SkinPreviewStorage()