fix(stage-ui): live2d model not loading

This commit is contained in:
Ayaka Neko
2025-08-21 03:45:46 +08:00
parent b064b59b0f
commit 91e05a64c5
6 changed files with 120 additions and 132 deletions
@@ -147,14 +147,14 @@ onMounted(() => updateThemeColor())
>
<div relative h-full w-full pb-4 top="44px">
<!-- Content -->
<div flex="~ col" mx-auto max-w-screen-xl>
<div flex="~ col" mx-auto max-w-screen-xl h="[calc(100dvh-44px-16px)]">
<PageHeader
:title="routeHeaderMetadata?.title"
:subtitle="routeHeaderMetadata?.subtitle"
:disable-back-button="route.path === '/settings'"
top="44px!" px-4
/>
<div px-4>
<div h-full px-4>
<RouterView />
</div>
</div>
@@ -1,22 +0,0 @@
<script setup lang="ts">
import { useLive2d } from '@proj-airi/stage-ui/stores'
defineEmits<{
(e: 'click'): void
}>()
const live2d = useLive2d()
</script>
<template>
<button
:disabled="live2d.loadingModel"
bg="neutral-100 dark:neutral-800"
hover="bg-neutral-200 dark:bg-neutral-700"
transition="all ease-in-out duration-250"
rounded
@click="$emit('click')"
>
<slot />
</button>
</template>
@@ -6,6 +6,7 @@ withDefaults(defineProps<{
icon: string
innerClass?: string
expand?: boolean
size?: 'sm' | 'md'
}>(), {
expand: true,
})
@@ -21,8 +22,8 @@ withDefaults(defineProps<{
hover="bg-neutral-200 dark:bg-neutral-700"
@click="slotProps.setVisible(!slotProps.visible)"
>
<div flex items-center gap-1.5>
<div :class="icon" size-6 />
<div flex items-center gap-1.5 :class="[size === 'sm' ? 'text-xs 2xl:text-sm' : '']">
<div :class="[icon, size === 'sm' ? 'size-4' : 'size-6']" />
{{ title }}
</div>
<div
@@ -32,7 +33,7 @@ withDefaults(defineProps<{
/>
</button>
</template>
<div grid gap-2 p-4 :class="innerClass">
<div grid gap-2 :class="[innerClass, size === 'sm' ? 'p-2' : 'p-4']">
<slot />
</div>
</Collapsable>
@@ -5,10 +5,10 @@ import localforage from 'localforage'
import { Checkbox, FieldRange, Input } from '@proj-airi/ui'
import { useFileDialog, useObjectUrl } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { ref, watch } from 'vue'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { Emotion, EmotionNeutralMotionName } from '../../../../constants'
import { Emotion } from '../../../../constants'
import { useLive2d, useSettings } from '../../../../stores'
import { Section } from '../../../Layouts'
import { Button } from '../../../Misc'
@@ -36,7 +36,6 @@ const live2d = useLive2d()
const {
modelFile,
motionMap,
loadingModel,
availableMotions,
modelUrl,
currentMotion,
@@ -49,30 +48,10 @@ modelFileDialog.onChange((files) => {
if (files && files.length > 0) {
motionMap.value = {}
modelFile.value = files[0]
loadingModel.value = true
live2d.shouldUpdateView()
}
})
watch(loadingModel, (value) => {
if (value) {
return
}
if (!modelFile.value) {
return
}
availableMotions.value.forEach((motion) => {
if (motion.motionName in Emotion) {
motionMap.value[motion.fileName] = motion.motionName
}
else {
motionMap.value[motion.fileName] = EmotionNeutralMotionName
}
})
})
async function patchMotionMap(source: File, motionMap: Record<string, string>): Promise<File> {
if (!Object.keys(motionMap).length)
return source
@@ -115,7 +94,6 @@ async function saveMotionMap() {
const patchedFile = await patchMotionMap(fileFromIndexedDB, motionMap.value)
modelFile.value = patchedFile
loadingModel.value = true
}
const exportObjectUrl = useObjectUrl(modelFile)
@@ -130,6 +108,8 @@ const exportObjectUrl = useObjectUrl(modelFile)
'bg-white/80 dark:bg-black/75',
'backdrop-blur-lg',
]"
size="sm"
:expand="false"
>
<Button variant="secondary" @click="$emit('switchToVRM')">
{{ t('settings.live2d.switch-to-vrm.change-to-vrm') }}
@@ -144,6 +124,8 @@ const exportObjectUrl = useObjectUrl(modelFile)
'bg-white/80 dark:bg-black/75',
'backdrop-blur-lg',
]"
size="sm"
:expand="false"
>
<Button variant="secondary" @click="modelFileDialog.open()">
{{ t('settings.live2d.change-model.from-file') }}...
@@ -151,7 +133,6 @@ const exportObjectUrl = useObjectUrl(modelFile)
<div flex items-center gap-2>
<Input
v-model="localModelUrl"
:disabled="loadingModel"
class="flex-1"
:placeholder="t('settings.live2d.change-model.from-url-placeholder')"
/>
@@ -169,6 +150,8 @@ const exportObjectUrl = useObjectUrl(modelFile)
'bg-white/80 dark:bg-black/75',
'backdrop-blur-lg',
]"
size="sm"
:expand="false"
>
<ColorPalette class="mb-4 mt-2" :colors="palette.map(hex => ({ hex, name: hex }))" mx-auto />
<Button variant="secondary" @click="$emit('extractColorsFromModel')">
@@ -184,6 +167,8 @@ const exportObjectUrl = useObjectUrl(modelFile)
'bg-white/80 dark:bg-black/75',
'backdrop-blur-lg',
]"
size="sm"
:expand="false"
>
<div v-for="motion in availableMotions" :key="motion.fileName" flex items-center justify-between text-sm>
<span font-medium font-mono>{{ motion.fileName }}</span>
@@ -221,6 +206,8 @@ const exportObjectUrl = useObjectUrl(modelFile)
'bg-white/80 dark:bg-black/75',
'backdrop-blur-lg',
]"
size="sm"
:expand="false"
>
<FieldRange v-model="scale" as="div" :min="0.5" :max="2" :step="0.01" :label="t('settings.live2d.scale-and-position.scale')">
<template #label>
@@ -261,6 +248,8 @@ const exportObjectUrl = useObjectUrl(modelFile)
'bg-white/80 dark:bg-black/75',
'backdrop-blur-lg',
]"
size="sm"
:expand="false"
>
<Checkbox
v-model="live2dDisableFocus"
@@ -4,7 +4,7 @@ import type { Cubism4InternalModel, InternalModel } from 'pixi-live2d-display/cu
import localforage from 'localforage'
import { breakpointsTailwind, useBreakpoints, useDark, useDebounceFn, useObjectUrl } from '@vueuse/core'
import { breakpointsTailwind, until, useBreakpoints, useDark, useDebounceFn, useObjectUrl } from '@vueuse/core'
import { formatHex } from 'culori'
import { storeToRefs } from 'pinia'
import { DropShadowFilter } from 'pixi-filters'
@@ -12,6 +12,7 @@ import { Live2DFactory, Live2DModel, MotionPriority } from 'pixi-live2d-display/
import { computed, onMounted, onUnmounted, ref, shallowRef, toRef, watch } from 'vue'
import { useLive2DIdleEyeFocus } from '../../../composables/live2d'
import { Emotion, EmotionNeutralMotionName } from '../../../constants/emotions'
import { useLive2d, useSettings } from '../../../stores'
type CubismModel = Cubism4InternalModel['coreModel']
@@ -77,6 +78,8 @@ const modelSrcNormalized = computed(() => {
return ''
})
const modelLoading = ref(false)
const offset = computed(() => parsePropsOffset())
const pixiApp = toRef(() => props.app)
@@ -124,9 +127,9 @@ function setScaleAndPosition() {
const {
modelFile,
loadingModel,
currentMotion,
availableMotions,
motionMap,
} = storeToRefs(useLive2d())
const {
@@ -137,8 +140,14 @@ const {
const localCurrentMotion = ref<{ group: string, index: number }>({ group: 'Idle', index: 0 })
async function loadModel() {
if (!pixiApp.value)
await until(modelLoading).not.toBeTruthy()
modelLoading.value = true
if (!pixiApp.value) {
modelLoading.value = false
return
}
if (model.value) {
pixiApp.value.stage.removeChild(model.value)
@@ -147,72 +156,83 @@ async function loadModel() {
}
if (!modelSrcNormalized.value) {
console.warn('No Live2D model source provided.')
modelLoading.value = false
return
}
const modelInstance = new Live2DModel<PixiLive2DInternalModel>()
if (modelFile.value) {
await Live2DFactory.setupLive2DModel(modelInstance, [modelFile.value], { autoInteract: false })
}
else {
await Live2DFactory.setupLive2DModel(modelInstance, modelSrcNormalized.value, { autoInteract: false })
}
try {
const modelInstance = new Live2DModel<PixiLive2DInternalModel>()
if (modelFile.value) {
await Live2DFactory.setupLive2DModel(modelInstance, [modelFile.value], { autoInteract: false })
}
else {
await Live2DFactory.setupLive2DModel(modelInstance, modelSrcNormalized.value, { autoInteract: false })
}
model.value = modelInstance
pixiApp.value.stage.addChild(model.value)
initialModelWidth.value = model.value.width
initialModelHeight.value = model.value.height
model.value.anchor.set(0.5, 0.5)
setScaleAndPosition()
model.value.on('hit', (hitAreas) => {
if (model.value && hitAreas.includes('body'))
model.value.motion('tap_body')
})
const internalModel = model.value.internalModel
const coreModel = internalModel.coreModel
const motionManager = internalModel.motionManager
coreModel.setParameterValueById('ParamMouthOpenY', mouthOpenSize.value)
availableMotions.value = Object.entries(motionManager.definitions).flatMap(([motionName, definition]) => {
if (!definition)
return []
return definition.map((motion: any, index: number) => ({
motionName,
motionIndex: index,
fileName: motion.File,
}))
}).filter(Boolean)
// Remove eye ball movements from idle motion group to prevent conflicts
// This is too hacky
// FIXME: it cannot blink if loading a model only have idle motion
if (motionManager.groups.idle) {
motionManager.motionGroups[motionManager.groups.idle]?.forEach((motion) => {
motion._motionData.curves.forEach((curve: any) => {
// TODO: After emotion mapper, stage editor, eye related parameters should be take cared to be dynamical instead of hardcoding
if (curve.id === 'ParamEyeBallX' || curve.id === 'ParamEyeBallY') {
curve.id = `_${curve.id}`
}
})
availableMotions.value.forEach((motion) => {
if (motion.motionName in Emotion) {
motionMap.value[motion.fileName] = motion.motionName
}
else {
motionMap.value[motion.fileName] = EmotionNeutralMotionName
}
})
}
// This is hacky too
const hookedUpdate = motionManager.update as (model: CubismModel, now: number) => boolean
motionManager.update = function (model: CubismModel, now: number) {
lastUpdateTime.value = now
model.value = modelInstance
pixiApp.value.stage.addChild(model.value)
initialModelWidth.value = model.value.width
initialModelHeight.value = model.value.height
model.value.anchor.set(0.5, 0.5)
setScaleAndPosition()
hookedUpdate?.call(this, model, now)
// Possibility 1: Only update eye focus when the model is idle
// Possibility 2: For models having no motion groups, currentGroup will be undefined while groups can be { idle: ... }
if (!motionManager.state.currentGroup || motionManager.state.currentGroup === motionManager.groups.idle) {
idleEyeFocus.update(internalModel, now)
model.value.on('hit', (hitAreas) => {
if (model.value && hitAreas.includes('body'))
model.value.motion('tap_body')
})
// If the model has eye blink parameters
if (internalModel.eyeBlink != null) {
const internalModel = model.value.internalModel
const coreModel = internalModel.coreModel
const motionManager = internalModel.motionManager
coreModel.setParameterValueById('ParamMouthOpenY', mouthOpenSize.value)
availableMotions.value = Object.entries(motionManager.definitions).flatMap(([motionName, definition]) => {
if (!definition)
return []
return definition.map((motion: any, index: number) => ({
motionName,
motionIndex: index,
fileName: motion.File,
}))
}).filter(Boolean)
// Remove eye ball movements from idle motion group to prevent conflicts
// This is too hacky
// FIXME: it cannot blink if loading a model only have idle motion
if (motionManager.groups.idle) {
motionManager.motionGroups[motionManager.groups.idle]?.forEach((motion) => {
motion._motionData.curves.forEach((curve: any) => {
// TODO: After emotion mapper, stage editor, eye related parameters should be take cared to be dynamical instead of hardcoding
if (curve.id === 'ParamEyeBallX' || curve.id === 'ParamEyeBallY') {
curve.id = `_${curve.id}`
}
})
})
}
// This is hacky too
const hookedUpdate = motionManager.update as (model: CubismModel, now: number) => boolean
motionManager.update = function (model: CubismModel, now: number) {
lastUpdateTime.value = now
hookedUpdate?.call(this, model, now)
// Possibility 1: Only update eye focus when the model is idle
// Possibility 2: For models having no motion groups, currentGroup will be undefined while groups can be { idle: ... }
if (!motionManager.state.currentGroup || motionManager.state.currentGroup === motionManager.groups.idle) {
idleEyeFocus.update(internalModel, now)
// If the model has eye blink parameters
if (internalModel.eyeBlink != null) {
// For the part of the auto eye blink implementation in pixi-live2d-display
//
// this.emit("beforeMotionUpdate");
@@ -233,27 +253,30 @@ async function loadModel() {
// and previously a always `true` was returned, eye blink parameters were never updated.
//
// Thous we are here to manually update the eye blink parameters within this hooked method
internalModel.eyeBlink.updateParameters(model, (now - lastUpdateTime.value) / 1000)
internalModel.eyeBlink.updateParameters(model, (now - lastUpdateTime.value) / 1000)
}
// still, mark the motion as updated
return true
}
// still, mark the motion as updated
return true
return false
}
return false
motionManager.on('motionStart', (group, index) => {
localCurrentMotion.value = { group, index }
})
// save to indexdb
if (modelFile.value) {
await localforage.setItem('live2dModel', modelFile.value)
}
emits('modelLoaded')
}
motionManager.on('motionStart', (group, index) => {
localCurrentMotion.value = { group, index }
})
// save to indexdb
if (modelFile.value) {
await localforage.setItem('live2dModel', modelFile.value)
finally {
modelLoading.value = false
}
emits('modelLoaded')
loadingModel.value = false
}
async function setMotion(motionName: string, index?: number) {
@@ -275,7 +298,7 @@ function updateDropShadowFilter() {
}
watch([() => props.width, () => props.height], () => handleResize())
watch(modelSrcNormalized, () => loadModel())
watch(modelSrcNormalized, async () => await loadModel(), { immediate: true })
watch(dark, updateDropShadowFilter, { immediate: true })
watch([model, themeColorsHue], updateDropShadowFilter)
watch(offset, setScaleAndPosition)
@@ -311,7 +334,6 @@ watch(focusAt, (value) => {
})
onMounted(async () => {
await loadModel()
updateDropShadowFilter()
})
-2
View File
@@ -61,7 +61,6 @@ export const useLive2d = defineStore('live2d', () => {
const defaultModelUrl = '/assets/live2d/models/hiyori_pro_zh.zip'
const modelUrl = useLocalStorage<string>('settings/live2d/model-src', defaultModelUrl)
const loadingModel = ref(false) // if set to true, the model will be loaded
const position = useLocalStorage('settings/live2d/position', { x: 0, y: 0 }) // position is relative to the center of the screen, units are %
const positionInPercentageString = computed(() => ({
x: `${position.value.x}%`,
@@ -75,7 +74,6 @@ export const useLive2d = defineStore('live2d', () => {
return {
modelFile,
modelUrl,
loadingModel,
position,
positionInPercentageString,
currentMotion,