fix(stage-ui): live2d model not loading
This commit is contained in:
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user