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:
@@ -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)"
|
||||
|
||||
@@ -8,3 +8,4 @@ export * from './onboarding'
|
||||
export * from './providers'
|
||||
export * from './server'
|
||||
export * from './settings'
|
||||
export * from './vrm'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user