fix: accounts card not having the right head

This commit is contained in:
Calum H.
2025-06-24 21:04:13 +01:00
parent a8be9664bb
commit 6d1bdec453
2 changed files with 178 additions and 8 deletions

View File

@@ -26,7 +26,7 @@
:class="{ expanded: mode === 'expanded', isolated: mode === 'isolated' }"
>
<div v-if="selectedAccount" class="selected account">
<Avatar size="xs" :src="`https://mc-heads.net/avatar/${selectedAccount.profile.id}/128`" />
<Avatar size="xs" :src="avatarUrl" />
<div>
<h4>{{ selectedAccount.profile.name }}</h4>
<p>Selected</p>
@@ -57,11 +57,7 @@
<div v-for="account in displayAccounts" :key="account.profile.id" class="account-row">
<Button class="option account" @click="setAccount(account)">
<Avatar
:src="
account.profile.id == selectedAccount.profile.id
? avatarUrl
: `https://mc-heads.net/avatar/${account.profile.id}/128`
"
:src="getAccountAvatarUrl(account)"
class="icon"
/>
<p>{{ account.profile.name }}</p>
@@ -82,7 +78,7 @@
<script setup>
import { DropdownIcon, PlusIcon, TrashIcon, LogInIcon, SpinnerIcon } from '@modrinth/assets'
import { Avatar, Button, Card } from '@modrinth/ui'
import { ref, computed, onMounted, onBeforeUnmount, onUnmounted } from 'vue'
import {ref, computed, onMounted, onBeforeUnmount, onUnmounted, watch} from 'vue'
import {
users,
remove_user,
@@ -95,6 +91,7 @@ import { trackEvent } from '@/helpers/analytics'
import { process_listener } from '@/helpers/events'
import { handleSevereError } from '@/store/error.js'
import { get_available_skins } from '@/helpers/skins'
import { getPlayerHeadUrl } from '@/helpers/rendering/batch-skin-renderer.ts'
defineProps({
mode: {
@@ -110,6 +107,7 @@ const accounts = ref({})
const loginDisabled = ref(false)
const defaultUser = ref()
const equippedSkin = ref(null)
const headUrlCache = ref(new Map())
async function refreshValues() {
defaultUser.value = await get_default_user().catch(handleError)
@@ -118,6 +116,15 @@ async function refreshValues() {
try {
const skins = await get_available_skins()
equippedSkin.value = skins.find((skin) => skin.is_equipped)
if (equippedSkin.value) {
try {
const headUrl = await getPlayerHeadUrl(equippedSkin.value)
headUrlCache.value.set(equippedSkin.value.texture_key, headUrl)
} catch (error) {
console.warn('Failed to get head render for equipped skin:', error)
}
}
} catch {
equippedSkin.value = null
}
@@ -140,11 +147,28 @@ const displayAccounts = computed(() =>
const avatarUrl = computed(() => {
if (equippedSkin.value?.texture_key) {
const cachedUrl = headUrlCache.value.get(equippedSkin.value.texture_key)
if (cachedUrl) {
return cachedUrl
}
return `https://mc-heads.net/avatar/${equippedSkin.value.texture_key}/128`
}
return `https://mc-heads.net/avatar/${selectedAccount.value.profile.id}/128`
if (selectedAccount.value?.profile?.id) {
return `https://mc-heads.net/avatar/${selectedAccount.value.profile.id}/128`
}
return 'https://launcher-files.modrinth.com/assets/steve_head.png'
})
function getAccountAvatarUrl(account) {
if (account.profile.id === selectedAccount.value?.profile?.id && equippedSkin.value?.texture_key) {
const cachedUrl = headUrlCache.value.get(equippedSkin.value.texture_key)
if (cachedUrl) {
return cachedUrl
}
}
return `https://mc-heads.net/avatar/${account.profile.id}/128`
}
const selectedAccount = computed(() =>
accounts.value.find((account) => account.profile.id === defaultUser.value),
)

View File

@@ -133,23 +133,167 @@ function getModelUrlForVariant(variant: string): string {
}
export const map = reactive(new Map<string, RenderResult>())
export const headMap = reactive(new Map<string, string>())
const DEBUG_MODE = false
export async function cleanupUnusedPreviews(skins: Skin[]): Promise<void> {
const validKeys = new Set<string>()
const validHeadKeys = new Set<string>()
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<Blob> {
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<string> {
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<string> {
return await generateHeadRender(skin)
}
export async function generateSkinPreviews(skins: Skin[], capes: Cape[]): Promise<void> {
const renderer = new BatchSkinRenderer()
const capeModelUrl = '/src/assets/models/cape.gltf'
@@ -203,6 +347,8 @@ export async function generateSkinPreviews(skins: Skin[], capes: Cape[]): Promis
} catch (error) {
console.warn('Failed to store skin preview in persistent storage:', error)
}
await generateHeadRender(skin)
}
} finally {
renderer.dispose()