feat(stage-ui): Live2D vs VRM page switch botton and VRM model uploading functionality (#290)

---------

Co-authored-by: Neko <neko@ayaka.moe>
This commit is contained in:
Lilia_Chen
2025-07-19 01:17:41 +08:00
committed by GitHub
co-authored by Neko
parent eeadf3b92d
commit d2ce04b21b
9 changed files with 280 additions and 11 deletions
@@ -56,6 +56,9 @@ live2d:
scale: Scale
x: X
'y': 'Y'
switch-to-vrm:
title: Switch to 3D Avator?
change-to-vrm: Click here to switch to the 3D avator setting (VRM)
theme-color-from-model:
title: Extract colors from model
button-extract:
@@ -536,3 +539,24 @@ theme:
Switch the base theme of AIRI, Light mode or Dark mode.
title: Settings
voices: Voice
vrm:
change-model:
from-file: Load from File
from-file-select: Select
from-url: Load from URL
from-url-confirm: Load
from-url-placeholder: Enter VRM model URL
title: Change Model
title: VRM Settings
scale-and-position:
title: Scale And Position
scale: Scale
x: X
'y': 'Y'
switch-to-vrm:
title: Switch to Live2D Avatar?
change-to-vrm: Click here to switch to the Live2D avatar setting
theme-color-from-model:
title: Extract colors from model
button-extract:
title: Extract
@@ -52,6 +52,9 @@ live2d:
scale: 缩放
x: X
'y': 'Y'
switch-to-vrm:
title: 想切换至3D虚拟形象?
change-to-vrm: 切换至3D虚拟形象设定页面(VRM
theme-color-from-model:
button-extract:
title: 提取
@@ -512,3 +515,23 @@ theme:
切换 AIRI 的基础主题,亮色模式或暗色模式。
title: 设置
voices: 声线
vrm:
change-model:
from-file: 从文件加载
from-file-select: 选择
from-url: 从 URL 加载
from-url-confirm: 加载
from-url-placeholder: 输入 VRM 模型 URL
title: 更换模型
title: VRM 设置
scale-and-position:
title: 缩放与位置
scale: 缩放
x: X
'y': 'Y'
switch-to-vrm:
title: 想切换至Live2D虚拟形象?
change-to-vrm: 切换至Live2D虚拟形象设定页面
theme-color-from-model:
button-extract:
title: 提取
@@ -20,6 +20,7 @@ defineProps<{
defineEmits<{
(e: 'extractColorsFromModel'): void
(e: 'switchToVRM'): void
}>()
const { t } = useI18n()
@@ -120,6 +121,19 @@ const exportObjectUrl = useObjectUrl(modelFile)
</script>
<template>
<Section
:title="t('settings.live2d.switch-to-vrm.title')"
icon="i-solar:magic-stick-3-bold-duotone"
:class="[
'rounded-xl',
'bg-white/80 dark:bg-black/75',
'backdrop-blur-lg',
]"
>
<Button variant="secondary" @click="$emit('switchToVRM')">
{{ t('settings.live2d.switch-to-vrm.change-to-vrm') }}
</Button>
</Section>
<Section
:title="t('settings.live2d.change-model.title')"
icon="i-solar:magic-stick-3-bold-duotone"
@@ -1,6 +1,100 @@
<script lang="ts" setup>
<script setup lang="ts">
import { Input } from '@proj-airi/ui'
import { useFileDialog } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useVRM } from '../../../../stores'
import { Section } from '../../../Layouts'
import { Button } from '../../../Misc'
import { ColorPalette } from '../../../Widgets'
defineProps<{
palette: string[]
}>()
defineEmits<{
(e: 'extractColorsFromModel'): void
(e: 'switchToLive2D'): void
}>()
const { t } = useI18n()
const modelFileDialog = useFileDialog({
accept: '.vrm',
})
const vrm = useVRM()
const {
modelFile,
loadSource,
loadingModel,
modelUrl,
} = storeToRefs(vrm)
const localModelUrl = ref(modelUrl.value)
modelFileDialog.onChange((files) => {
if (files && files.length > 0) {
modelFile.value = files[0]
loadSource.value = 'file'
loadingModel.value = true
}
})
</script>
<template>
<div />
<Section
:title="t('settings.vrm.switch-to-vrm.title')"
icon="i-solar:magic-stick-3-bold-duotone"
:class="[
'rounded-xl',
'bg-white/80 dark:bg-black/75',
'backdrop-blur-lg',
]"
>
<Button variant="secondary" @click="$emit('switchToLive2D')">
{{ t('settings.vrm.switch-to-vrm.change-to-vrm') }}
</Button>
</Section>
<Section
:title="t('settings.vrm.change-model.title')"
icon="i-solar:magic-stick-3-bold-duotone"
inner-class="text-sm"
:class="[
'rounded-xl',
'bg-white/80 dark:bg-black/75',
'backdrop-blur-lg',
]"
>
<Button variant="secondary" @click="modelFileDialog.open()">
{{ t('settings.vrm.change-model.from-file') }}...
</Button>
<div flex items-center gap-2>
<Input
v-model="localModelUrl"
:disabled="loadingModel"
class="flex-1"
:placeholder="t('settings.vrm.change-model.from-url-placeholder')"
/>
<Button size="sm" variant="secondary" @click="() => { modelUrl = localModelUrl; loadSource = 'url'; loadingModel = true }">
{{ t('settings.vrm.change-model.from-url') }}
</Button>
</div>
</Section>
<Section
:title="t('settings.vrm.theme-color-from-model.title')"
icon="i-solar:magic-stick-3-bold-duotone"
inner-class="text-sm"
:class="[
'rounded-xl',
'bg-white/80 dark:bg-black/75',
'backdrop-blur-lg',
]"
>
<ColorPalette class="mb-4 mt-2" :colors="palette.map(hex => ({ hex, name: hex }))" mx-auto />
<Button variant="secondary" @click="$emit('extractColorsFromModel')">
{{ t('settings.vrm.theme-color-from-model.button-extract.title') }}
</Button>
</Section>
</template>
@@ -1,6 +1,57 @@
<script lang="ts" setup>
<script setup lang="ts">
import { useVRM } from '@proj-airi/stage-ui/stores'
import { OrbitControls } from '@tresjs/cientos'
import { TresCanvas } from '@tresjs/core'
import { useElementBounding } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { computed, ref } from 'vue'
import { VRMModel } from '../../../Scenes'
const emit = defineEmits<{
(e: 'loadModelProgress', value: number): void
(e: 'error', value: unknown): void
}>()
const VRMContainerRef = ref<HTMLDivElement>()
const { width, height } = useElementBounding(VRMContainerRef)
const { modelFile, modelUrl, loadSource, selectedModel } = storeToRefs(useVRM())
const cameraPositionX = ref(-0.17)
const cameraPositionY = ref(0)
const cameraPositionZ = ref(-1)
const vrmModelPositionX = ref(-0.18)
const vrmModelPositionY = ref(-1.32)
const vrmModelPositionZ = ref(-0.24)
const modelRef = ref<{
setExpression: (expression: string) => void
}>()
defineExpose({
setExpression: (expression: string) => {
modelRef.value?.setExpression(expression)
},
})
</script>
<template>
<div />
<div ref="VRMContainerRef" w="100%" h="100%">
<TresCanvas :alpha="true" :antialias="true" :width="width" :height="height">
<OrbitControls />
<TresPerspectiveCamera :position="[cameraPositionX, cameraPositionY, cameraPositionZ]" />
<TresDirectionalLight :color="0xFFFFFF" :intensity="1.2" :position="[1, 1, 1]" />
<TresAmbientLight :color="0xFFFFFF" :intensity="1.5" />
<VRMModel
ref="modelRef"
:key="selectedModel"
:model="selectedModel"
idle-animation="/assets/vrm/animations/idle_loop.vrma"
:position="[vrmModelPositionX, vrmModelPositionY, vrmModelPositionZ]"
:paused="false"
@load-model-progress="(val) => emit('loadModelProgress', val)"
@error="(val) => emit('error', val)"
/>
</TresCanvas>
</div>
</template>
@@ -3,6 +3,8 @@ import { storeToRefs } from 'pinia'
import Live2D from './Live2D.vue'
import Live2DScene from './Live2DScene.vue'
import VRM from './VRM.vue'
import VRMScene from './VRMScene.vue'
import { useSettings } from '../../../../stores/settings'
@@ -36,8 +38,14 @@ const { stageView } = storeToRefs(useSettings())
: []),
]"
>
<Live2D :palette="palette" @extract-colors-from-model="$emit('extractColorsFromModel')" />
<Live2D :palette="palette" @extract-colors-from-model="$emit('extractColorsFromModel')" @switch-to-v-r-m="stageView = '3d'" />
</div>
</template>
<!-- VRM component for 3D stage view -->
<template v-if="stageView === '3d'">
<VRMScene />
<div flex="~ col gap-2">
<VRM :palette="palette" @extract-colors-from-model="$emit('extractColorsFromModel')" @switch-to-live-2-d="stageView = '2d'" />
</div>
</template>
<!-- TODO: VRM component for 3D stage view -->
</template>
@@ -2,7 +2,8 @@
import { TransitionVertical } from '@proj-airi/ui'
import { OrbitControls } from '@tresjs/cientos'
import { TresCanvas } from '@tresjs/core'
import { ref } from 'vue'
import { storeToRefs } from 'pinia'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import DataGuiRange from '../DataGui/Range.vue'
@@ -10,9 +11,9 @@ import Collapsable from '../Misc/Collapsable.vue'
import Screen from '../Misc/Screen.vue'
import VRMModel from './VRM/Model.vue'
import { useVRM } from '../../stores'
const props = defineProps<{
model: string
idleAnimation: string
paused: boolean
}>()
@@ -23,6 +24,8 @@ const emit = defineEmits<{
const show = ref(false)
const { modelFile, modelUrl, loadSource, selectedModel } = storeToRefs(useVRM())
const cameraPositionX = ref(-0.17)
const cameraPositionY = ref(0)
const cameraPositionZ = ref(-1)
@@ -52,8 +55,8 @@ defineExpose({
<TresAmbientLight :color="0xFFFFFF" :intensity="1.5" />
<VRMModel
ref="modelRef"
:model="props.model"
:idle-animation="props.idleAnimation"
:model="selectedModel"
idle-animation="/assets/vrm/animations/idle_loop.vrma"
:position="[vrmModelPositionX, vrmModelPositionY, vrmModelPositionZ]"
:paused="props.paused"
@load-model-progress="(val) => emit('loadModelProgress', val)"
+1
View File
@@ -8,3 +8,4 @@ export * from './onboarding'
export * from './providers'
export * from './server'
export * from './settings'
export * from './vrm'
+51
View File
@@ -0,0 +1,51 @@
import { useLocalStorage } from '@vueuse/core'
import { defineStore } from 'pinia'
import { computed, ref, watch } from 'vue'
export const useVRM = defineStore('vrm', () => {
const modelFile = ref<File>()
const modelUrl = ref<string>('/assets/vrm/models/AvatarSample-B/AvatarSample_B.vrm')
const loadSource = ref<'file' | 'url'>('url')
const loadingModel = ref(false)
const position = useLocalStorage('settings/vrm/position', { x: 0, y: 0 })
const positionInPercentageString = computed(() => ({
x: `${position.value.x}%`,
y: `${position.value.y}%`,
}))
const modelObjectUrl = ref<string>()
// Manage the object URL lifecycle to prevent memory leaks
watch(modelFile, (newFile) => {
if (modelObjectUrl.value) {
URL.revokeObjectURL(modelObjectUrl.value)
modelObjectUrl.value = undefined
}
if (newFile) {
modelObjectUrl.value = URL.createObjectURL(newFile)
}
})
// Centralized computed property for the model source
const selectedModel = computed(() => {
if (loadSource.value === 'file' && modelObjectUrl.value) {
return modelObjectUrl.value
}
if (loadSource.value === 'url' && modelUrl.value) {
return modelUrl.value
}
// Fallback model
return '/assets/vrm/models/AvatarSample-B/AvatarSample_B.vrm'
})
return {
modelFile,
modelUrl,
loadSource,
loadingModel,
position,
positionInPercentageString,
selectedModel, // Expose the new computed property
}
})