From f95d0d78f2f9ed5985cb46da3284573525ea7db6 Mon Sep 17 00:00:00 2001 From: IMB11 Date: Wed, 2 Jul 2025 21:32:15 +0100 Subject: [PATCH] feat(app): skins frontend (#3657) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: typo fix and formatting tidyups * refactor(theseus): extend auth subsystem to fetch complete user profiles * chore: fix new `prettier` lints * chore: document differences between similar `Credentials` methods * chore: remove dead `profile_run_credentials` plugin command * feat(app): skin selector backend * enh(app/skin-selector): better DB intension through deferred FKs, further PNG validations * chore: fix comment typo spotted by Copilot * fix: less racy auth token refresh logic This may help with issues reported by users where the access token is invalid and can't be used to join servers over long periods of time. * tweak(app-lib): improve consistency of skin field serialization case * fix(app-lib/minecraft_skins): fix custom skin removal from DB not working * Begin skins frontend * Cape preview * feat: start on SkinPreviewRenderer * feat: setting for nametag * feat: hide nametag setting (sql) * fix: positioning of meshes * fix: lighting * fix: allow dragging off-bounds * fix: better color mapping * feat: hide nametag setting (impl) * feat: Start on edit modal + cape button cleanup + renderer fixes * feat: Finish new skin modal * feat: finish cape modal * feat: skin rendering on load * fix: logic for Skins.vue * fix: types * fix: types (for modal + renderer) * feat: Editing? * fix: renderer not updating variant * fix: mojang username not modrinth username * feat: batched skin rendering - remove vzge references (apart from capes, wip) * feat: fix sizing on SkinButton and SkinLikeButton, also implement bust positioning * feat: capes in preview renderer & baked renders * fix: lint fixes * refactor: Start on cleanup and polish * fix: hide error notification when logged out * revert: .gltf formatting * chore(app-frontend): fix typos * fix(app-lib): delay account skin data deletion to next reboot This gives users an opportunity to not unexpectedly lose skin data in case they log off on accident. * fix: login button & provide/inject AccountsCard * polish: skin buttons * fix: imports * polish: use figma values * polish: tweak underneath shadow * polish: cursor grab * polish: remove green bg from CapeLikeTextButton when selected. * polish: modal tweaks * polish: grid tweaks + start on upload skin modal * polish: drag and drop file flow * polish: button positioning in SkinButton * fix: lint issues * polish: deduplicate model+cape stuff and fix layout * fix: lint issues * fix: camel case requirement for make-default * polish: use indexed db to persist skin previews * fix: lint issues * polish: add skin icon sizing * polish: theme fixes * feat: animation system for skin preview renderer * feat(app/minecraft_skins): save current custom external skin when equipping skins * fix: cape button & dynamic nametag sizing * feat(theseus): add `normalize_skin_texture` Tauri command This command lets the app frontend opt in to normalizing the texture of any skin, which may be in either the legacy 64x32 or newer 64x64 format, to the newer 64x64 format for display purposes. * chore: Rust build fixes * feat: start impl of skin normalization on frontend * feat(theseus): change parameter type of `normalize_skin_texture` Tauri command * fix: normalization * fix(theseus): make new `normalize_skin_texture` command usable * feat: finish normalization impl * fix: vueuse issue * fix: use optimistic approach when changing skins/capes. * fix: nametag cleanup + scroll fix * fix: edit modal computedAsync not fast enough for skin preview renderer * feat: classic player model animations * chore: fix new Clippy lint * fix(app-lib): actually delete custom skins with no cape overrides * fix(app-lib): handle repeated addition of the same skin properly * refactor(app-lib): simplify DB connection logic a little * fix: various improvements * feat: slim animations * fix: z-fighting on models * fix: shading + lighting improvements * fix: shadows * fix: polish * fix: polish * fix: accounts card not having the right head * fix: lint issues * fix: build issue * feat: drag and drop func * fix: temp disable drag and drop in the modal * Revert "fix: temp disable drag and drop in the modal" This reverts commit 33500c564e3f85e6c0a2e83dd9700deda892004d. * fix: drag and drop working * fix: lint * fix: better media queries * feat(app/skins): revert current custom external skin storing on equip This reverts commit 0155262ddd081c8677654619a09e814088fdd8b0. * regen pnpm lock * pnpm fix * Make default capes a little more clear * Lint --------- Co-authored-by: Alejandro González Co-authored-by: Prospector --- Cargo.lock | 9 + Cargo.toml | 5 + apps/app-frontend/.prettierignore | 1 + apps/app-frontend/package.json | 3 + apps/app-frontend/src/App.vue | 17 +- apps/app-frontend/src/assets/models/cape.gltf | 1 + .../src/assets/models/classic_player.gltf | 1 + .../src/assets/models/slim_player.gltf | 1 + .../src/assets/skins/herobrine.png | Bin 0 -> 2159 bytes apps/app-frontend/src/assets/skins/steve.png | Bin 0 -> 1311 bytes .../src/components/ui/AccountsCard.vue | 103 ++- .../src/components/ui/ErrorModal.vue | 6 +- .../src/components/ui/modal/ModalWrapper.vue | 10 +- .../ui/settings/AppearanceSettings.vue | 10 +- .../src/components/ui/skin/EditSkinModal.vue | 415 +++++++++++ .../components/ui/skin/SelectCapeModal.vue | 143 ++++ .../components/ui/skin/UploadSkinModal.vue | 140 ++++ .../helpers/rendering/batch-skin-renderer.ts | 353 ++++++++++ apps/app-frontend/src/helpers/settings.ts | 1 + apps/app-frontend/src/helpers/skins.ts | 163 +++++ .../helpers/storage/skin-preview-storage.ts | 118 ++++ apps/app-frontend/src/pages/Index.vue | 19 +- apps/app-frontend/src/pages/Skins.vue | 525 ++++++++++++++ apps/app-frontend/src/pages/index.js | 3 +- apps/app-frontend/src/routes.js | 8 + apps/app-frontend/tailwind.config.js | 1 + apps/app-playground/src/main.rs | 5 +- apps/app/build.rs | 19 +- apps/app/capabilities/plugins.json | 1 + apps/app/src/api/minecraft_skins.rs | 104 +++ apps/app/src/api/mod.rs | 1 + apps/app/src/api/profile.rs | 17 - apps/app/src/main.rs | 1 + .../src/public/news/feed/articles.json | 2 +- ...6dd3eed86ba421c83e74fe284609a8020bd22.json | 12 + ...5e50912064c29ebf1a1e5ead79c44c37e64c.json} | 6 +- ...fbdbd066d51f88cd2bcfed613f756edbd2944.json | 12 + ...22546583aa19ea7088682d718c64ed5d5f1c5.json | 20 + ...1055fe47287d7f99be41215c25c1019d439b9.json | 12 + ...a31ae4bd7532d02f8b00b43d5645351941ca.json} | 60 +- ...b712aba58908a66dd7bbd64c293b9ee7a1523.json | 12 + ...cdcf73da199ea6ac05ee3ee798ece80d877cf.json | 2 +- ...ae58e2d7a414e76906700518806e494cd0246.json | 20 + ...68169cfd8226b57bacd8c270d7777fc6883ac.json | 32 + ...b878b6789927df5adf50986fe642c8afcb681.json | 12 + ...7674be5035f385e0d85e759d4bbf9bca54f20.json | 12 + ...d1821b46d60f5cb79685c489aaebf13b35d24.json | 12 + packages/app-lib/Cargo.toml | 13 +- .../20250413162050_skin-selector.sql | 80 +++ .../20250514181748_skin_nametag_setting.sql | 1 + packages/app-lib/src/api/logs.rs | 20 +- packages/app-lib/src/api/minecraft_auth.rs | 4 +- packages/app-lib/src/api/minecraft_skins.rs | 530 ++++++++++++++ .../assets/default/MissingNo.png | Bin 0 -> 435 bytes .../assets/default/default_skins.rs | 213 ++++++ .../assets/test/MissingNo_normalized.png | Bin 0 -> 7155 bytes .../src/api/minecraft_skins/png_util.rs | 323 +++++++++ packages/app-lib/src/api/mod.rs | 1 + packages/app-lib/src/api/profile/mod.rs | 3 +- packages/app-lib/src/api/settings.rs | 2 + packages/app-lib/src/error.rs | 32 +- packages/app-lib/src/launcher/args.rs | 17 +- packages/app-lib/src/launcher/mod.rs | 5 +- packages/app-lib/src/state/db.rs | 36 +- .../app-lib/src/state/legacy_converter.rs | 17 +- packages/app-lib/src/state/minecraft_auth.rs | 569 ++++++++++++--- .../app-lib/src/state/minecraft_skins/mod.rs | 180 +++++ .../src/state/minecraft_skins/mojang_api.rs | 142 ++++ packages/app-lib/src/state/mod.rs | 2 + packages/app-lib/src/state/settings.rs | 10 +- packages/assets/generated-icons.ts | 2 + packages/assets/icons/change-skin.svg | 5 + packages/assets/styles/variables.scss | 6 + .../compiled/creator_updates_july_2025.ts | 2 +- packages/ui/package.json | 5 + .../src/components/base/ScrollablePanel.vue | 41 +- packages/ui/src/components/index.ts | 7 + .../ui/src/components/skin/CapeButton.vue | 108 +++ .../components/skin/CapeLikeTextButton.vue | 63 ++ .../ui/src/components/skin/SkinButton.vue | 142 ++++ .../components/skin/SkinLikeTextButton.vue | 67 ++ .../components/skin/SkinPreviewRenderer.vue | 666 ++++++++++++++++++ .../ui/src/composables/dynamic-font-size.ts | 117 +++ packages/ui/src/composables/index.ts | 1 + packages/ui/src/vue-shims.d.ts | 5 + packages/utils/index.ts | 1 + packages/utils/package.json | 2 + packages/utils/three/skin-rendering.ts | 207 ++++++ packages/utils/utils.ts | 5 + pnpm-lock.yaml | 256 ++++++- 90 files changed, 6105 insertions(+), 233 deletions(-) create mode 100644 apps/app-frontend/src/assets/models/cape.gltf create mode 100644 apps/app-frontend/src/assets/models/classic_player.gltf create mode 100644 apps/app-frontend/src/assets/models/slim_player.gltf create mode 100644 apps/app-frontend/src/assets/skins/herobrine.png create mode 100644 apps/app-frontend/src/assets/skins/steve.png create mode 100644 apps/app-frontend/src/components/ui/skin/EditSkinModal.vue create mode 100644 apps/app-frontend/src/components/ui/skin/SelectCapeModal.vue create mode 100644 apps/app-frontend/src/components/ui/skin/UploadSkinModal.vue create mode 100644 apps/app-frontend/src/helpers/rendering/batch-skin-renderer.ts create mode 100644 apps/app-frontend/src/helpers/skins.ts create mode 100644 apps/app-frontend/src/helpers/storage/skin-preview-storage.ts create mode 100644 apps/app-frontend/src/pages/Skins.vue create mode 100644 apps/app/src/api/minecraft_skins.rs create mode 100644 packages/app-lib/.sqlx/query-27a4ca00ab9d1647bf63287169f6dd3eed86ba421c83e74fe284609a8020bd22.json rename packages/app-lib/.sqlx/{query-759e4ffe30ebc4f8602256cb419ef15732d84bcebb9ca15225dbabdc0f46ba2d.json => query-3613473fb4d836ee0fb3c292e6bf5e50912064c29ebf1a1e5ead79c44c37e64c.json} (88%) create mode 100644 packages/app-lib/.sqlx/query-3d15e7eb66971e70500e8718236fbdbd066d51f88cd2bcfed613f756edbd2944.json create mode 100644 packages/app-lib/.sqlx/query-3f3d3c2d77c1bcaf4044b612c3822546583aa19ea7088682d718c64ed5d5f1c5.json create mode 100644 packages/app-lib/.sqlx/query-4c8063f9ce2fd7deec9b69e0b2c1055fe47287d7f99be41215c25c1019d439b9.json rename packages/app-lib/.sqlx/{query-d90a2f2f823fc546661a94af07249758c5ca82db396268bca5087bac88f733d9.json => query-5193f519f021b2e7013cdb67a6e1a31ae4bd7532d02f8b00b43d5645351941ca.json} (80%) create mode 100644 packages/app-lib/.sqlx/query-545b01d8cc1e79ff5d4136887fbb712aba58908a66dd7bbd64c293b9ee7a1523.json create mode 100644 packages/app-lib/.sqlx/query-957f184e28e4921ff3922f3e74aae58e2d7a414e76906700518806e494cd0246.json create mode 100644 packages/app-lib/.sqlx/query-aae88809ada53e13441352e315f68169cfd8226b57bacd8c270d7777fc6883ac.json create mode 100644 packages/app-lib/.sqlx/query-e9449930a74c6a6151c3d868042b878b6789927df5adf50986fe642c8afcb681.json create mode 100644 packages/app-lib/.sqlx/query-faa8437519571b147b0135054847674be5035f385e0d85e759d4bbf9bca54f20.json create mode 100644 packages/app-lib/.sqlx/query-fd494269d944b179ade61876669d1821b46d60f5cb79685c489aaebf13b35d24.json create mode 100644 packages/app-lib/migrations/20250413162050_skin-selector.sql create mode 100644 packages/app-lib/migrations/20250514181748_skin_nametag_setting.sql create mode 100644 packages/app-lib/src/api/minecraft_skins.rs create mode 100644 packages/app-lib/src/api/minecraft_skins/assets/default/MissingNo.png create mode 100644 packages/app-lib/src/api/minecraft_skins/assets/default/default_skins.rs create mode 100644 packages/app-lib/src/api/minecraft_skins/assets/test/MissingNo_normalized.png create mode 100644 packages/app-lib/src/api/minecraft_skins/png_util.rs create mode 100644 packages/app-lib/src/state/minecraft_skins/mod.rs create mode 100644 packages/app-lib/src/state/minecraft_skins/mojang_api.rs create mode 100644 packages/assets/icons/change-skin.svg create mode 100644 packages/ui/src/components/skin/CapeButton.vue create mode 100644 packages/ui/src/components/skin/CapeLikeTextButton.vue create mode 100644 packages/ui/src/components/skin/SkinButton.vue create mode 100644 packages/ui/src/components/skin/SkinLikeTextButton.vue create mode 100644 packages/ui/src/components/skin/SkinPreviewRenderer.vue create mode 100644 packages/ui/src/composables/dynamic-font-size.ts create mode 100644 packages/utils/three/skin-rendering.ts diff --git a/Cargo.lock b/Cargo.lock index 60565594f..5f58d5a52 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8095,6 +8095,7 @@ dependencies = [ "tokio-stream", "tracing", "url", + "uuid 1.17.0", "webpki-roots 0.26.11", ] @@ -8177,6 +8178,7 @@ dependencies = [ "stringprep", "thiserror 2.0.12", "tracing", + "uuid 1.17.0", "whoami", ] @@ -8216,6 +8218,7 @@ dependencies = [ "stringprep", "thiserror 2.0.12", "tracing", + "uuid 1.17.0", "whoami", ] @@ -8242,6 +8245,7 @@ dependencies = [ "thiserror 2.0.12", "tracing", "url", + "uuid 1.17.0", ] [[package]] @@ -9004,11 +9008,13 @@ dependencies = [ "async-walkdir", "async_zip", "base64 0.22.1", + "bytemuck", "bytes", "chardetng", "chrono", "daedalus", "dashmap", + "data-url", "dirs", "discord-rich-presence", "dunce", @@ -9019,17 +9025,20 @@ dependencies = [ "fs4", "futures", "hashlink", + "heck 0.5.0", "hickory-resolver", "indicatif", "notify", "notify-debouncer-mini", "p256", "paste", + "png", "quartz_nbt", "quick-xml 0.37.5", "rand 0.8.5", "regex", "reqwest", + "rgb", "serde", "serde_ini", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index b06dab88c..d0101db0b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,7 @@ async-tungstenite = { version = "0.29.1", default-features = false, features = [ async-walkdir = "2.1.0" base64 = "0.22.1" bitflags = "2.9.1" +bytemuck = "1.23.0" bytes = "1.10.1" censor = "0.3.0" chardetng = "0.1.17" @@ -47,6 +48,7 @@ color-thief = "0.2.2" console-subscriber = "0.4.1" daedalus = { path = "packages/daedalus" } dashmap = "6.1.0" +data-url = "0.3.1" deadpool-redis = "0.21.1" dirs = "6.0.0" discord-rich-presence = "0.2.5" @@ -61,6 +63,7 @@ fs4 = { version = "0.13.1", default-features = false } futures = { version = "0.3.31", default-features = false } futures-util = "0.3.31" hashlink = "0.10.0" +heck = "0.5.0" hex = "0.4.3" hickory-resolver = "0.25.2" hmac = "0.12.1" @@ -90,6 +93,7 @@ notify = { version = "8.0.0", default-features = false } notify-debouncer-mini = { version = "0.6.0", default-features = false } p256 = "0.13.2" paste = "1.0.15" +png = "0.17.16" prometheus = "0.14.0" quartz_nbt = "0.2.9" quick-xml = "0.37.5" @@ -98,6 +102,7 @@ rand_chacha = "=0.3.1" # Locked on 0.3 until we can update rand to 0.9 redis = "=0.31.0" # Locked on 0.31 until deadpool-redis updates to 0.32 regex = "1.11.1" reqwest = { version = "0.12.20", default-features = false } +rgb = "0.8.50" rust_decimal = { version = "1.37.2", features = [ "serde-with-float", "serde-with-str", diff --git a/apps/app-frontend/.prettierignore b/apps/app-frontend/.prettierignore index 581edad3d..0cb3e84e5 100644 --- a/apps/app-frontend/.prettierignore +++ b/apps/app-frontend/.prettierignore @@ -1 +1,2 @@ **/dist +*.gltf diff --git a/apps/app-frontend/package.json b/apps/app-frontend/package.json index 6fccb56ce..0f2d7892c 100644 --- a/apps/app-frontend/package.json +++ b/apps/app-frontend/package.json @@ -25,12 +25,15 @@ "@tauri-apps/plugin-os": "^2.2.1", "@tauri-apps/plugin-updater": "^2.7.1", "@tauri-apps/plugin-window-state": "^2.2.2", + "@types/three": "^0.172.0", "@vintl/vintl": "^4.4.1", + "@vueuse/core": "^11.1.0", "dayjs": "^1.11.10", "floating-vue": "^5.2.2", "ofetch": "^1.3.4", "pinia": "^2.1.7", "posthog-js": "^1.158.2", + "three": "^0.172.0", "vite-svg-loader": "^5.1.0", "vue": "^3.5.13", "vue-multiselect": "3.0.0", diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue index 5673661d4..17b2b5198 100644 --- a/apps/app-frontend/src/App.vue +++ b/apps/app-frontend/src/App.vue @@ -1,8 +1,9 @@ diff --git a/apps/app-frontend/src/components/ui/skin/SelectCapeModal.vue b/apps/app-frontend/src/components/ui/skin/SelectCapeModal.vue new file mode 100644 index 000000000..c6c25080e --- /dev/null +++ b/apps/app-frontend/src/components/ui/skin/SelectCapeModal.vue @@ -0,0 +1,143 @@ + + diff --git a/apps/app-frontend/src/components/ui/skin/UploadSkinModal.vue b/apps/app-frontend/src/components/ui/skin/UploadSkinModal.vue new file mode 100644 index 000000000..818922eff --- /dev/null +++ b/apps/app-frontend/src/components/ui/skin/UploadSkinModal.vue @@ -0,0 +1,140 @@ + + + diff --git a/apps/app-frontend/src/helpers/rendering/batch-skin-renderer.ts b/apps/app-frontend/src/helpers/rendering/batch-skin-renderer.ts new file mode 100644 index 000000000..75729c5aa --- /dev/null +++ b/apps/app-frontend/src/helpers/rendering/batch-skin-renderer.ts @@ -0,0 +1,353 @@ +import * as THREE from 'three' +import type { Skin, Cape } from '../skins' +import { get_normalized_skin_texture, 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 + backwards: string +} + +class BatchSkinRenderer { + private renderer: THREE.WebGLRenderer + private readonly scene: THREE.Scene + private readonly camera: THREE.PerspectiveCamera + private currentModel: THREE.Group | null = null + + constructor(width: number = 360, height: number = 504) { + const canvas = document.createElement('canvas') + canvas.width = width + canvas.height = height + + this.renderer = new THREE.WebGLRenderer({ + canvas: canvas, + antialias: true, + alpha: true, + preserveDrawingBuffer: true, + }) + + this.renderer.outputColorSpace = THREE.SRGBColorSpace + this.renderer.toneMapping = THREE.NoToneMapping + this.renderer.toneMappingExposure = 10.0 + this.renderer.setClearColor(0x000000, 0) + this.renderer.setSize(width, height) + + this.scene = new THREE.Scene() + this.camera = new THREE.PerspectiveCamera(20, width / height, 0.4, 1000) + + const ambientLight = new THREE.AmbientLight(0xffffff, 2) + const directionalLight = new THREE.DirectionalLight(0xffffff, 1.2) + directionalLight.castShadow = true + directionalLight.position.set(2, 4, 3) + this.scene.add(ambientLight) + this.scene.add(directionalLight) + } + + public async renderSkin( + textureUrl: string, + modelUrl: string, + capeUrl?: string, + capeModelUrl?: string, + ): Promise { + await this.setupModel(modelUrl, textureUrl, capeModelUrl, capeUrl) + + const headPart = this.currentModel!.getObjectByName('Head') + let lookAtTarget: [number, number, number] + + if (headPart) { + const headPosition = new THREE.Vector3() + headPart.getWorldPosition(headPosition) + lookAtTarget = [headPosition.x, headPosition.y - 0.3, headPosition.z] + } else { + throw new Error("Failed to find 'Head' object in model.") + } + + const frontCameraPos: [number, number, number] = [-1.3, 1, 6.3] + const backCameraPos: [number, number, number] = [-1.3, 1, -2.5] + + const forwards = await this.renderView(frontCameraPos, lookAtTarget) + const backwards = await this.renderView(backCameraPos, lookAtTarget) + + return { forwards, backwards } + } + + private async renderView( + cameraPosition: [number, number, number], + lookAtPosition: [number, number, number], + ): Promise { + this.camera.position.set(...cameraPosition) + this.camera.lookAt(...lookAtPosition) + + this.renderer.render(this.scene, this.camera) + + return new Promise((resolve, reject) => { + this.renderer.domElement.toBlob((blob) => { + if (blob) { + const url = URL.createObjectURL(blob) + resolve(url) + } else { + reject(new Error('Failed to create blob from canvas')) + } + }, 'image/png') + }) + } + + private async setupModel( + modelUrl: string, + textureUrl: string, + capeModelUrl?: string, + capeUrl?: string, + ): Promise { + if (this.currentModel) { + this.scene.remove(this.currentModel) + } + + const { model } = await setupSkinModel(modelUrl, textureUrl, capeModelUrl, capeUrl) + + const group = new THREE.Group() + group.add(model) + group.position.set(0, 0.3, 1.95) + group.scale.set(0.8, 0.8, 0.8) + + this.scene.add(group) + this.currentModel = group + } + + public dispose(): void { + this.renderer.dispose() + disposeCaches() + } +} + +function getModelUrlForVariant(variant: string): string { + switch (variant) { + case 'SLIM': + return '/src/assets/models/slim_player.gltf' + case 'CLASSIC': + case 'UNKNOWN': + default: + return '/src/assets/models/classic_player.gltf' + } +} + +export const map = reactive(new Map()) +export const headMap = reactive(new Map()) +const DEBUG_MODE = false + +export async function cleanupUnusedPreviews(skins: Skin[]): Promise { + const validKeys = new Set() + const validHeadKeys = new Set() + + for (const skin of skins) { + const key = `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}` + const headKey = `${skin.texture_key}-head` + validKeys.add(key) + validHeadKeys.add(headKey) + } + + try { + await skinPreviewStorage.cleanupInvalidKeys(validKeys) + await skinPreviewStorage.cleanupInvalidKeys(validHeadKeys) + } catch (error) { + console.warn('Failed to cleanup unused skin previews:', error) + } +} + +export async function generatePlayerHeadBlob(skinUrl: string, size: number = 64): Promise { + return new Promise((resolve, reject) => { + const img = new Image() + img.crossOrigin = 'anonymous' + + img.onload = () => { + try { + const sourceCanvas = document.createElement('canvas') + const sourceCtx = sourceCanvas.getContext('2d') + + if (!sourceCtx) { + throw new Error('Could not get 2D context from source canvas') + } + + sourceCanvas.width = img.width + sourceCanvas.height = img.height + + sourceCtx.drawImage(img, 0, 0) + + const outputCanvas = document.createElement('canvas') + const outputCtx = outputCanvas.getContext('2d') + + if (!outputCtx) { + throw new Error('Could not get 2D context from output canvas') + } + + outputCanvas.width = size + outputCanvas.height = size + + outputCtx.imageSmoothingEnabled = false + + const headImageData = sourceCtx.getImageData(8, 8, 8, 8) + + const headCanvas = document.createElement('canvas') + const headCtx = headCanvas.getContext('2d') + + if (!headCtx) { + throw new Error('Could not get 2D context from head canvas') + } + + headCanvas.width = 8 + headCanvas.height = 8 + headCtx.putImageData(headImageData, 0, 0) + + outputCtx.drawImage(headCanvas, 0, 0, 8, 8, 0, 0, size, size) + + const hatImageData = sourceCtx.getImageData(40, 8, 8, 8) + + const hatCanvas = document.createElement('canvas') + const hatCtx = hatCanvas.getContext('2d') + + if (!hatCtx) { + throw new Error('Could not get 2D context from hat canvas') + } + + hatCanvas.width = 8 + hatCanvas.height = 8 + hatCtx.putImageData(hatImageData, 0, 0) + + const hatPixels = hatImageData.data + let hasHat = false + + for (let i = 3; i < hatPixels.length; i += 4) { + if (hatPixels[i] > 0) { + hasHat = true + break + } + } + + if (hasHat) { + outputCtx.drawImage(hatCanvas, 0, 0, 8, 8, 0, 0, size, size) + } + + outputCanvas.toBlob((blob) => { + if (blob) { + resolve(blob) + } else { + reject(new Error('Failed to create blob from canvas')) + } + }, 'image/png') + } catch (error) { + reject(error) + } + } + + img.onerror = () => { + reject(new Error('Failed to load skin texture image')) + } + + img.src = skinUrl + }) +} + +async function generateHeadRender(skin: Skin): Promise { + const headKey = `${skin.texture_key}-head` + + if (headMap.has(headKey)) { + if (DEBUG_MODE) { + const url = headMap.get(headKey)! + URL.revokeObjectURL(url) + headMap.delete(headKey) + } else { + return headMap.get(headKey)! + } + } + + try { + const cached = await skinPreviewStorage.retrieve(headKey) + if (cached && typeof cached === 'string') { + headMap.set(headKey, cached) + return cached + } + } catch (error) { + console.warn('Failed to retrieve cached head render:', error) + } + + const skinUrl = await get_normalized_skin_texture(skin) + const headBlob = await generatePlayerHeadBlob(skinUrl, 64) + const headUrl = URL.createObjectURL(headBlob) + + headMap.set(headKey, headUrl) + + try { + await skinPreviewStorage.store(headKey, headUrl) + } catch (error) { + console.warn('Failed to store head render in persistent storage:', error) + } + + return headUrl +} + +export async function getPlayerHeadUrl(skin: Skin): Promise { + return await generateHeadRender(skin) +} + +export async function generateSkinPreviews(skins: Skin[], capes: Cape[]): Promise { + const renderer = new BatchSkinRenderer() + const capeModelUrl = '/src/assets/models/cape.gltf' + + try { + for (const skin of skins) { + const key = `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}` + + if (map.has(key)) { + if (DEBUG_MODE) { + const result = map.get(key)! + URL.revokeObjectURL(result.forwards) + URL.revokeObjectURL(result.backwards) + map.delete(key) + } 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 { + variant = await determineModelType(skin.texture) + } catch (error) { + console.error(`Failed to determine model type for skin ${key}:`, error) + variant = 'CLASSIC' + } + } + + const modelUrl = getModelUrlForVariant(variant) + const cape: Cape | undefined = capes.find((_cape) => _cape.id === skin.cape_id) + const renderResult = await renderer.renderSkin( + await get_normalized_skin_texture(skin), + modelUrl, + cape?.texture, + capeModelUrl, + ) + + map.set(key, renderResult) + + try { + await skinPreviewStorage.store(key, renderResult) + } catch (error) { + console.warn('Failed to store skin preview in persistent storage:', error) + } + + await generateHeadRender(skin) + } + } finally { + renderer.dispose() + await cleanupUnusedPreviews(skins) + } +} diff --git a/apps/app-frontend/src/helpers/settings.ts b/apps/app-frontend/src/helpers/settings.ts index 2988d34d8..c256575a4 100644 --- a/apps/app-frontend/src/helpers/settings.ts +++ b/apps/app-frontend/src/helpers/settings.ts @@ -37,6 +37,7 @@ export type AppSettings = { theme: ColorTheme default_page: 'home' | 'library' collapsed_navigation: boolean + hide_nametag_skins_page: boolean advanced_rendering: boolean native_decorations: boolean toggle_sidebar: boolean diff --git a/apps/app-frontend/src/helpers/skins.ts b/apps/app-frontend/src/helpers/skins.ts new file mode 100644 index 000000000..40c48d63c --- /dev/null +++ b/apps/app-frontend/src/helpers/skins.ts @@ -0,0 +1,163 @@ +import { invoke } from '@tauri-apps/api/core' +import { handleError } from '@/store/notifications' +import { arrayBufferToBase64 } from '@modrinth/utils' + +export interface Cape { + id: string + name: string + texture: string + is_default: boolean + is_equipped: boolean +} + +export type SkinModel = 'CLASSIC' | 'SLIM' | 'UNKNOWN' +export type SkinSource = 'default' | 'custom_external' | 'custom' + +export interface Skin { + texture_key: string + name?: string + variant: SkinModel + cape_id?: string + texture: string + source: SkinSource + is_equipped: boolean +} + +export const DEFAULT_MODEL_SORTING = ['Steve', 'Alex'] as string[] + +export const DEFAULT_MODELS: Record = { + Steve: 'CLASSIC', + Alex: 'SLIM', + Zuri: 'CLASSIC', + Sunny: 'CLASSIC', + Noor: 'SLIM', + Makena: 'SLIM', + Kai: 'CLASSIC', + Efe: 'SLIM', + Ari: 'CLASSIC', +} + +export function filterSavedSkins(list: Skin[]) { + const customSkins = list.filter((s) => s.source !== 'default') + fixUnknownSkins(customSkins).catch(handleError) + return customSkins +} + +export async function determineModelType(texture: string): Promise<'SLIM' | 'CLASSIC'> { + return new Promise((resolve, reject) => { + const canvas = document.createElement('canvas') + const context = canvas.getContext('2d') + + if (!context) { + return reject(new Error('Failed to create canvas rendering context.')) + } + + const image = new Image() + image.crossOrigin = 'anonymous' + image.src = texture + + image.onload = () => { + canvas.width = image.width + canvas.height = image.height + + context.drawImage(image, 0, 0) + + const armX = 44 + const armY = 16 + const armWidth = 4 + const armHeight = 12 + + const imageData = context.getImageData(armX, armY, armWidth, armHeight).data + + for (let y = 0; y < armHeight; y++) { + const alphaIndex = (3 + y * armWidth) * 4 + 3 + if (imageData[alphaIndex] !== 0) { + resolve('CLASSIC') + return + } + } + + canvas.remove() + resolve('SLIM') + } + + image.onerror = () => { + canvas.remove() + reject(new Error('Failed to load the image.')) + } + }) +} + +export async function fixUnknownSkins(list: Skin[]) { + const unknownSkins = list.filter((s) => s.variant === 'UNKNOWN') + for (const unknownSkin of unknownSkins) { + unknownSkin.variant = await determineModelType(unknownSkin.texture) + } +} + +export function filterDefaultSkins(list: Skin[]) { + return list + .filter((s) => s.source === 'default' && (!s.name || s.variant === DEFAULT_MODELS[s.name])) + .sort((a, b) => { + const aIndex = a.name ? DEFAULT_MODEL_SORTING.indexOf(a.name) : -1 + const bIndex = b.name ? DEFAULT_MODEL_SORTING.indexOf(b.name) : -1 + return (aIndex === -1 ? Infinity : aIndex) - (bIndex === -1 ? Infinity : bIndex) + }) +} + +export async function get_available_capes(): Promise { + return invoke('plugin:minecraft-skins|get_available_capes', {}) +} + +export async function get_available_skins(): Promise { + return invoke('plugin:minecraft-skins|get_available_skins', {}) +} + +export async function add_and_equip_custom_skin( + textureBlob: Uint8Array, + variant: SkinModel, + capeOverride?: Cape, +): Promise { + await invoke('plugin:minecraft-skins|add_and_equip_custom_skin', { + textureBlob, + variant, + capeOverride, + }) +} + +export async function set_default_cape(cape?: Cape): Promise { + await invoke('plugin:minecraft-skins|set_default_cape', { + cape, + }) +} + +export async function equip_skin(skin: Skin): Promise { + await invoke('plugin:minecraft-skins|equip_skin', { + skin, + }) +} + +export async function remove_custom_skin(skin: Skin): Promise { + await invoke('plugin:minecraft-skins|remove_custom_skin', { + skin, + }) +} + +export async function get_normalized_skin_texture(skin: Skin): Promise { + const data = await normalize_skin_texture(skin.texture) + const base64 = arrayBufferToBase64(data) + return `data:image/png;base64,${base64}` +} + +export async function normalize_skin_texture(texture: Uint8Array | string): Promise { + return await invoke('plugin:minecraft-skins|normalize_skin_texture', { texture }) +} + +export async function unequip_skin(): Promise { + await invoke('plugin:minecraft-skins|unequip_skin') +} + +export async function get_dragged_skin_data(path: string): Promise { + const data = await invoke('plugin:minecraft-skins|get_dragged_skin_data', { path }) + return new Uint8Array(data) +} 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..2e4990850 --- /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 forwardsBlob = await fetch(result.forwards).then((r) => r.blob()) + const backwardsBlob = await fetch(result.backwards).then((r) => r.blob()) + + const transaction = this.db!.transaction(['previews'], 'readwrite') + const store = transaction.objectStore('previews') + + 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() diff --git a/apps/app-frontend/src/pages/Index.vue b/apps/app-frontend/src/pages/Index.vue index e5c1e0689..3eba1ba68 100644 --- a/apps/app-frontend/src/pages/Index.vue +++ b/apps/app-frontend/src/pages/Index.vue @@ -10,6 +10,7 @@ import dayjs from 'dayjs' import { get_search_results } from '@/helpers/cache.js' import type { SearchResult } from '@modrinth/utils' import RecentWorldsList from '@/components/ui/world/RecentWorldsList.vue' +import type { GameInstance } from '@/helpers/types' const route = useRoute() const breadcrumbs = useBreadcrumbs() @@ -82,13 +83,15 @@ async function refreshFeaturedProjects() { await fetchInstances() await refreshFeaturedProjects() -const unlistenProfile = await profile_listener(async (e) => { - await fetchInstances() +const unlistenProfile = await profile_listener( + async (e: { event: string; profile_path_id: string }) => { + await fetchInstances() - if (e.event === 'added' || e.event === 'created' || e.event === 'removed') { - await refreshFeaturedProjects() - } -}) + if (e.event === 'added' || e.event === 'created' || e.event === 'removed') { + await refreshFeaturedProjects() + } + }, +) onUnmounted(() => { unlistenProfile() @@ -97,8 +100,8 @@ onUnmounted(() => {