feat:移除了弹窗,服务器添加sls

This commit is contained in:
AxTps committed 2026-09-08 22:39:45 +08:00
commit 6a295f9a7a
4082 files changed
+1322534

No files matched your search

@@ -0,0 +1,167 @@
import { reactive } from 'vue'
import type { Skin } from '../skins'
import { get_normalized_skin_texture } from '../skins'
import { headStorage } from '../storage/head-storage'
import { skinPreviewStorage } from '../storage/skin-preview-storage'
export interface RenderResult {
forwards: string
}
export interface RawRenderResult {
forwards: Blob
}
export const skinBlobUrlMap = reactive(new Map<string, RenderResult>())
export const headBlobUrlMap = reactive(new Map<string, string>())
const headRenderPromises = new Map<string, Promise<string>>()
const DEBUG_MODE = false
const HEAD_RENDER_VERSION = 8
export function getHeadRenderKey(textureKey: string): string {
return `${textureKey}-head-v${HEAD_RENDER_VERSION}`
}
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 = getHeadRenderKey(skin.texture_key)
validKeys.add(key)
validHeadKeys.add(headKey)
}
try {
await skinPreviewStorage.cleanupInvalidKeys(validKeys)
await headStorage.cleanupInvalidKeys(validHeadKeys)
} catch (error) {
console.warn('Failed to cleanup unused skin previews:', error)
}
}
export async function generatePlayerHeadBlob(skinUrl: string): Promise<Blob> {
return new Promise((resolve, reject) => {
const img = new Image()
img.crossOrigin = 'anonymous'
img.onload = () => {
try {
if (img.width !== 64 || img.height !== 64) {
throw new Error(`Expected normalized 64x64 skin texture, got ${img.width}x${img.height}`)
}
const outerLayerCanvas = document.createElement('canvas')
outerLayerCanvas.width = 8
outerLayerCanvas.height = 8
const outerLayerCtx = outerLayerCanvas.getContext('2d')
if (!outerLayerCtx) {
throw new Error('Could not get 2D context for outer skin layer')
}
outerLayerCtx.drawImage(img, 40, 8, 8, 8, 0, 0, 8, 8)
const hasOuterLayer = outerLayerCtx
.getImageData(0, 0, 8, 8)
.data.some((channel, index) => index % 4 === 3 && channel > 0)
const outputSize = hasOuterLayer ? 72 : 64
const baseOffset = hasOuterLayer ? 4 : 0
const outputCanvas = document.createElement('canvas')
outputCanvas.width = outputSize
outputCanvas.height = outputSize
const outputCtx = outputCanvas.getContext('2d')
if (!outputCtx) {
throw new Error('Could not get 2D context from output canvas')
}
outputCtx.imageSmoothingEnabled = false
outputCtx.drawImage(img, 8, 8, 8, 8, baseOffset, baseOffset, 64, 64)
if (hasOuterLayer) {
outputCtx.drawImage(outerLayerCanvas, 0, 0, 8, 8, 0, 0, outputSize, outputSize)
}
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
})
}
export async function generateHeadRender(skin: Skin): Promise<string> {
const headKey = getHeadRenderKey(skin.texture_key)
if (headBlobUrlMap.has(headKey)) {
if (DEBUG_MODE) {
const url = headBlobUrlMap.get(headKey)!
URL.revokeObjectURL(url)
headBlobUrlMap.delete(headKey)
} else {
return headBlobUrlMap.get(headKey)!
}
}
const pendingRender = headRenderPromises.get(headKey)
if (pendingRender) return await pendingRender
const renderPromise = loadHeadRender(skin, headKey)
headRenderPromises.set(headKey, renderPromise)
try {
return await renderPromise
} finally {
if (headRenderPromises.get(headKey) === renderPromise) {
headRenderPromises.delete(headKey)
}
}
}
async function loadHeadRender(skin: Skin, headKey: string): Promise<string> {
if (!DEBUG_MODE) {
try {
const cachedHeadUrl = await headStorage.retrieve(headKey)
if (cachedHeadUrl) {
headBlobUrlMap.set(headKey, cachedHeadUrl)
return cachedHeadUrl
}
} catch (error) {
console.warn('Failed to retrieve cached head render:', error)
}
}
const skinUrl = await get_normalized_skin_texture(skin)
const headBlob = await generatePlayerHeadBlob(skinUrl)
const headUrl = URL.createObjectURL(headBlob)
headBlobUrlMap.set(headKey, headUrl)
try {
await headStorage.store(headKey, headBlob)
} 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)
}
@@ -0,0 +1,334 @@
import { ClassicPlayerModel, SlimPlayerModel } from '@modrinth/assets'
import {
applyCapeTexture,
createTransparentTexture,
disposeCaches,
setupSkinModel,
} from '@modrinth/ui/src/utils/webgl/skin-rendering'
import * as THREE from 'three'
import type { Cape, Skin } from '../skins'
import { determineModelType, get_normalized_skin_texture } from '../skins'
import { headStorage } from '../storage/head-storage'
import { skinPreviewStorage } from '../storage/skin-preview-storage'
import {
cleanupUnusedPreviews,
generateHeadRender,
getHeadRenderKey,
headBlobUrlMap,
type RawRenderResult,
type RenderResult,
skinBlobUrlMap,
} from './batch-skin-renderer'
class BatchSkinRenderer {
private renderer: THREE.WebGLRenderer | null = null
private scene: THREE.Scene | null = null
private camera: THREE.PerspectiveCamera | null = null
private currentModel: THREE.Group | null = null
private transparentTexture: THREE.Texture | null = null
private readonly width: number
private readonly height: number
constructor(width: number = 360, height: number = 504) {
this.width = width
this.height = height
}
private initializeRenderer(): void {
if (this.renderer) return
const canvas = document.createElement('canvas')
canvas.width = this.width
canvas.height = this.height
this.renderer = new THREE.WebGLRenderer({
canvas: canvas,
antialias: true,
alpha: true,
preserveDrawingBuffer: true,
})
this.renderer.outputColorSpace = THREE.SRGBColorSpace
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2))
this.renderer.shadowMap.enabled = false
this.renderer.toneMapping = THREE.NoToneMapping
this.renderer.toneMappingExposure = 10.0
this.renderer.setClearColor(0x000000, 0)
this.renderer.setSize(this.width, this.height)
this.scene = new THREE.Scene()
this.camera = new THREE.PerspectiveCamera(20, this.width / this.height, 0.4, 1000)
const ambientLight = new THREE.AmbientLight(0xffffff, 2)
const directionalLight = new THREE.DirectionalLight(0xffffff, 1.2)
directionalLight.castShadow = false
directionalLight.position.set(2, 4, 3)
this.scene.add(ambientLight)
this.scene.add(directionalLight)
}
public async renderSkin(
textureUrl: string,
modelUrl: string,
capeUrl?: string,
): Promise<RawRenderResult> {
this.initializeRenderer()
this.clearScene()
await this.setupModel(modelUrl, textureUrl, 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 forwards = await this.renderView(frontCameraPos, lookAtTarget)
return { forwards }
}
private async renderView(
cameraPosition: [number, number, number],
lookAtPosition: [number, number, number],
): Promise<Blob> {
if (!this.camera || !this.renderer || !this.scene) {
throw new Error('Renderer not initialized')
}
this.camera.position.set(...cameraPosition)
this.camera.lookAt(...lookAtPosition)
this.renderer.render(this.scene, this.camera)
return await new Promise<Blob>((resolve, reject) => {
this.renderer!.domElement.toBlob(
(blob) => {
if (blob) {
resolve(blob)
} else {
reject(new Error('Failed to create blob from rendered canvas'))
}
},
'image/webp',
0.9,
)
})
}
private async setupModel(modelUrl: string, textureUrl: string, capeUrl?: string): Promise<void> {
if (!this.scene) {
throw new Error('Renderer not initialized')
}
const { model } = await setupSkinModel(modelUrl, textureUrl, capeUrl)
if (!capeUrl) {
applyCapeTexture(model, null, this.getTransparentTexture())
}
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
}
private getTransparentTexture(): THREE.Texture {
if (!this.transparentTexture) {
this.transparentTexture = createTransparentTexture()
}
return this.transparentTexture
}
private clearScene(): void {
if (!this.scene || !this.currentModel) return
this.currentModel.traverse((object) => {
const mesh = object as THREE.Mesh
if (mesh.isMesh && mesh.userData.threeDSkinLayersApplied) {
mesh.geometry.dispose()
}
})
this.scene.remove(this.currentModel)
this.currentModel.clear()
this.currentModel = null
}
public dispose(): void {
this.clearScene()
if (this.transparentTexture) {
this.transparentTexture.dispose()
this.transparentTexture = null
}
if (this.renderer) {
this.renderer.dispose()
}
this.renderer = null
this.scene = null
this.camera = null
disposeCaches()
}
}
function getModelUrlForVariant(variant: string): string {
switch (variant) {
case 'SLIM':
return SlimPlayerModel
case 'CLASSIC':
case 'UNKNOWN':
default:
return ClassicPlayerModel
}
}
const DEBUG_MODE = false
let sharedRenderer: BatchSkinRenderer | null = null
let latestPreviewGeneration = 0
let previewGenerationQueue: Promise<void> = Promise.resolve()
function getSharedRenderer(): BatchSkinRenderer {
if (!sharedRenderer) {
sharedRenderer = new BatchSkinRenderer()
}
return sharedRenderer
}
export function disposeSharedRenderer(): void {
if (sharedRenderer) {
sharedRenderer.dispose()
sharedRenderer = null
}
}
export function generateSkinPreviews(skins: Skin[], capes: Cape[]): Promise<void> {
const generation = ++latestPreviewGeneration
const skinsSnapshot = [...skins]
const capesSnapshot = [...capes]
const generationPromise = previewGenerationQueue.then(() =>
generateSkinPreviewsForGeneration(skinsSnapshot, capesSnapshot, generation),
)
previewGenerationQueue = generationPromise.catch(() => {})
return generationPromise
}
async function generateSkinPreviewsForGeneration(
skins: Skin[],
capes: Cape[],
generation: number,
): Promise<void> {
const isCurrentGeneration = () => generation === latestPreviewGeneration
try {
const skinKeys = skins.map(
(skin) => `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}`,
)
const headKeys = skins.map((skin) => getHeadRenderKey(skin.texture_key))
const [cachedSkinPreviews, cachedHeadPreviews] = await Promise.all([
skinPreviewStorage.batchRetrieve(skinKeys),
headStorage.batchRetrieve(headKeys),
])
if (!isCurrentGeneration()) return
for (let i = 0; i < skins.length; i++) {
const skinKey = skinKeys[i]
const headKey = headKeys[i]
const rawCached = cachedSkinPreviews[skinKey]
if (rawCached && !skinBlobUrlMap.has(skinKey)) {
const cached: RenderResult = {
forwards: URL.createObjectURL(rawCached.forwards),
}
skinBlobUrlMap.set(skinKey, cached)
}
const cachedHead = cachedHeadPreviews[headKey]
if (cachedHead && !headBlobUrlMap.has(headKey)) {
headBlobUrlMap.set(headKey, URL.createObjectURL(cachedHead))
}
}
for (const skin of skins) {
if (!isCurrentGeneration()) return
const key = `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}`
if (skinBlobUrlMap.has(key)) {
if (DEBUG_MODE) {
const result = skinBlobUrlMap.get(key)!
URL.revokeObjectURL(result.forwards)
skinBlobUrlMap.delete(key)
} else continue
}
const renderer = getSharedRenderer()
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 rawRenderResult = await renderer.renderSkin(
await get_normalized_skin_texture(skin),
modelUrl,
cape?.texture,
)
if (!isCurrentGeneration()) return
const renderResult: RenderResult = {
forwards: URL.createObjectURL(rawRenderResult.forwards),
}
skinBlobUrlMap.set(key, renderResult)
try {
await skinPreviewStorage.store(key, rawRenderResult)
} catch (error) {
console.warn('Failed to store skin preview in persistent storage:', error)
}
const headKey = getHeadRenderKey(skin.texture_key)
if (!headBlobUrlMap.has(headKey)) {
await generateHeadRender(skin)
}
}
} finally {
disposeSharedRenderer()
if (isCurrentGeneration()) {
await cleanupUnusedPreviews(skins)
await skinPreviewStorage.debugCalculateStorage()
await headStorage.debugCalculateStorage()
}
}
}